Skip to content

Commit

Permalink
Merge pull request #2596 from leo-editor/ekr-links
Browse files Browse the repository at this point in the history
PR for #2593: create clickable links when pasting into log pane
  • Loading branch information
edreamleo committed Apr 12, 2022
2 parents 639b121 + 1227b78 commit afbfce9
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 36 deletions.
2 changes: 2 additions & 0 deletions .mypy.ini
Expand Up @@ -44,6 +44,7 @@ exclude =

# Settings for particular files...

# Core files that should be fully annotated...
[mypy-leo.core.leoGlobals,leo.core.leoNodes,leo.core.leoAst,leo.core.leoBackground]
disallow_untyped_defs = True
disallow_incomplete_defs = False
Expand All @@ -53,6 +54,7 @@ disallow_incomplete_defs = False
follow_imports = skip
ignore_missing_imports = True

# Don't check third-party code.
[mypy-leo.core.leoRope,leo.external.npyscreen]
follow_imports = skip
ignore_missing_imports = True
3 changes: 1 addition & 2 deletions leo/commands/testCommands.py
Expand Up @@ -335,8 +335,7 @@ def test_vim(event=None):
@g.command('test-gui')
def test_gui(event=None):
"""Run all gui-related unit tests."""
g.run_unit_tests('leo.unittests.test_gui.TestNullGui')
g.run_unit_tests('leo.unittests.test_gui.TestQtGui')
g.run_unit_tests('leo.unittests.test_gui')
#@-others
#@@language python
#@@tabwidth -4
Expand Down
86 changes: 80 additions & 6 deletions leo/core/leoFrame.py
Expand Up @@ -7,12 +7,14 @@
"""
#@+<< imports >>
#@+node:ekr.20120219194520.10464: ** << imports >> (leoFrame)
import time
import os
import re
from typing import List, Optional, Tuple
from leo.core import leoGlobals as g
from leo.core import leoColorizer # NullColorizer is a subclass of ColorizerMixin
from leo.core import leoMenu
from leo.core import leoNodes
assert time

#@-<< imports >>
#@+<< About handling events >>
#@+node:ekr.20031218072017.2410: ** << About handling events >>
Expand Down Expand Up @@ -1035,6 +1037,11 @@ def pasteText(self, event=None, middleButton=False):
# Update the widget.
if i != j:
w.delete(i, j)
# #2593: Replace link patterns with html links.
if wname.startswith('log'):
s = c.frame.log.put_html_links(s)
if s is None:
return # create_html_links has done all the work.
w.insert(i, s)
w.see(i + len(s) + 2)
if wname.startswith('body'):
Expand Down Expand Up @@ -1260,14 +1267,81 @@ def orderedTabNames(self, LeoLog=None):
#@+node:ekr.20070302094848.9: *3* LeoLog.numberOfVisibleTabs
def numberOfVisibleTabs(self):
return len([val for val in list(self.frameDict.values()) if val is not None])
#@+node:ekr.20070302101304: *3* LeoLog.put & putnl
#@+node:ekr.20070302101304: *3* LeoLog.put, putnl & helper
# All output to the log stream eventually comes here.

def put(self, s, color=None, tabName='Log', from_redirect=False, nodeLink=None):
print(s)

def putnl(self, tabName='Log'):
pass # print ('')
pass
#@+node:ekr.20220410180439.1: *4* LeoLog.put_html_links & helper
# To do: error patterns for black and pyflakes.

mypy_pat = re.compile(r'^(.+?):([0-9]+): (error|note): (.*)\s*$')
pylint_pat = re.compile(r'^(.*):\s*([0-9]+)[,:]\s*[0-9]+:.*?\(.*\)\s*$')
python_pat = re.compile(r'^\s*File\s+"(.*?)",\s*line\s*([0-9]+)\s*$')

error_patterns = (mypy_pat, pylint_pat, python_pat)

link_table: List[Tuple[int, int, re.Pattern]] = [
# (fn_i, line_i, pattern)
(1, 2, mypy_pat),
(1, 2, pylint_pat),
(1, 2, python_pat),
]

def put_html_links(self, s: str) -> Optional[str]:
"""
Case 1: if s contains any matches against known error patterns.
Output lines, one-by-one, to the log.
Return None, as a flag to LeoFrame.pastText
Case 2: Return s
"""
c = self.c
lines = g.splitLines(s)
# Step 1: return s if no lines match. This is an efficiency measure.
if not any(pat.match(line) for line in lines for pat in self.error_patterns):
# g.trace('No patterns matched')
return s
# Step 2: Output each line using log.put, with or without a nodeLink kwarg
for line in lines:
for fn_i, line_i, pattern in self.link_table:
m = pattern.match(line)
if m:
filename = m.group(fn_i)
line_number = m.group(line_i)
p = self.find_at_file_node(filename) # Try to find a matching @<file> node.
if p:
url = p.get_UNL()
self.put(line, nodeLink=f"{url}::-{line_number}") # Use global line.
else:
# g.trace('Not found', filename)
self.put(line)
break
else: # no match
self.put(line)
return None
#@+node:ekr.20220412084258.1: *5* LeoLog.find_at_file_node
def find_at_file_node(self, filename):
"""Find a position corresponding to filename s"""
c = self.c
target1 = os.path.normpath(filename)
parts = target1.split(os.sep)
candidates = list(p for p in c.all_positions() if p.isAnyAtFileNode())
while parts:
target = os.sep.join(parts)
parts.pop(0)
# Search twice, prefering exact matches.
for p in candidates:
if target == os.path.normpath(p.anyAtFileNodeName()):
return p
for p in candidates:
if os.path.normpath(p.anyAtFileNodeName()).endswith(target):
return p
return None

#@+node:ekr.20070302094848.10: *3* LeoLog.renameTab
def renameTab(self, oldName, newName):
pass
Expand Down Expand Up @@ -1992,15 +2066,15 @@ def oops(self):
#@+node:ekr.20041012083237.3: *3* NullLog.put and putnl
def put(self, s, color=None, tabName='Log', from_redirect=False, nodeLink=None):
# print('(nullGui) print',repr(s))
if self.enabled:
if self.enabled and not g.unitTesting:
try:
g.pr(s, newline=False)
except UnicodeError:
s = s.encode('ascii', 'replace')
g.pr(s, newline=False)

def putnl(self, tabName='Log'):
if self.enabled:
if self.enabled and not g.unitTesting:
g.pr('')
#@+node:ekr.20060124085830: *3* NullLog.tabs
def clearTab(self, tabName, wrap='none'):
Expand Down
12 changes: 6 additions & 6 deletions leo/core/leoGlobals.py
Expand Up @@ -7143,7 +7143,7 @@ def subprocess_wrapper(cmdlst: str) -> Tuple:
g.pr(so, se)
#@+node:ekr.20040321065415: *3* g.find*Node*
#@+others
#@+node:ekr.20210303123423.3: *4* findNodeAnywhere
#@+node:ekr.20210303123423.3: *4* g.findNodeAnywhere
def findNodeAnywhere(c: Cmdr, headline: str, exact: bool=True) -> Optional[Pos]:
h = headline.strip()
for p in c.all_unique_positions(copy=False):
Expand All @@ -7154,19 +7154,19 @@ def findNodeAnywhere(c: Cmdr, headline: str, exact: bool=True) -> Optional[Pos]:
if p.h.strip().startswith(h):
return p.copy()
return None
#@+node:ekr.20210303123525.1: *4* findNodeByPath
#@+node:ekr.20210303123525.1: *4* g.findNodeByPath
def findNodeByPath(c: Cmdr, path: str) -> Optional[Pos]:
"""Return the first @<file> node in Cmdr c whose path is given."""
if not os.path.isabs(path): # #2049. Only absolute paths could possibly work.
g.trace(f"path not absolute: {path}")
g.trace(f"path not absolute: {repr(path)}")
return None
path = g.os_path_normpath(path) # #2049. Do *not* use os.path.normpath.
for p in c.all_positions():
if p.isAnyAtFileNode():
if path == g.os_path_normpath(g.fullPath(c, p)): # #2049. Do *not* use os.path.normpath.
return p
return None
#@+node:ekr.20210303123423.1: *4* findNodeInChildren
#@+node:ekr.20210303123423.1: *4* g.findNodeInChildren
def findNodeInChildren(c: Cmdr, p: Pos, headline: str, exact: bool=True) -> Optional[Pos]:
"""Search for a node in v's tree matching the given headline."""
p1 = p.copy()
Expand All @@ -7179,7 +7179,7 @@ def findNodeInChildren(c: Cmdr, p: Pos, headline: str, exact: bool=True) -> Opti
if p.h.strip().startswith(h):
return p.copy()
return None
#@+node:ekr.20210303123423.2: *4* findNodeInTree
#@+node:ekr.20210303123423.2: *4* g.findNodeInTree
def findNodeInTree(c: Cmdr, p: Pos, headline: str, exact: bool=True) -> Optional[Pos]:
"""Search for a node in v's tree matching the given headline."""
h = headline.strip()
Expand All @@ -7192,7 +7192,7 @@ def findNodeInTree(c: Cmdr, p: Pos, headline: str, exact: bool=True) -> Optional
if p.h.strip().startswith(h):
return p.copy()
return None
#@+node:ekr.20210303123423.4: *4* findTopLevelNode
#@+node:ekr.20210303123423.4: *4* g.findTopLevelNode
def findTopLevelNode(c: Cmdr, headline: str, exact: bool=True) -> Optional[Pos]:
h = headline.strip()
for p in c.rootPosition().self_and_siblings(copy=False):
Expand Down
54 changes: 32 additions & 22 deletions leo/plugins/qt_frame.py
Expand Up @@ -3232,8 +3232,8 @@ def onContextMenu(self, point):
# #1286.
c, w = self.c, self
g.app.gui.onContextMenu(c, w, point)
#@+node:ekr.20110605121601.18321: *3* LeoQtLog.put & putnl
#@+node:ekr.20110605121601.18322: *4* LeoQtLog.put
#@+node:ekr.20110605121601.18321: *3* LeoQtLog.put and helpers
#@+node:ekr.20110605121601.18322: *4* LeoQtLog.put & helper
def put(self, s, color=None, tabName='Log', from_redirect=False, nodeLink=None):
"""
Put s to the Qt Log widget, converting to html.
Expand All @@ -3244,15 +3244,7 @@ def put(self, s, color=None, tabName='Log', from_redirect=False, nodeLink=None):
c = self.c
if g.app.quitting or not c or not c.exists:
return
# Note: g.actualColor does all color translation.
if color:
color = leoColor.getColor(color)
if not color:
# #788: First, fall back to 'log_black_color', not 'black.
color = c.config.getColor('log-black-color')
if not color:
# Should never be necessary.
color = 'black'
color = self.resolve_color(color)
self.selectTab(tabName or 'Log')
# Must be done after the call to selectTab.
wrapper = self.logCtrl
Expand All @@ -3264,16 +3256,7 @@ def put(self, s, color=None, tabName='Log', from_redirect=False, nodeLink=None):
g.trace('BAD widget', w.__class__.__name__)
return
sb = w.horizontalScrollBar()
s = s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
# #884: Always convert leading blanks and tabs to &nbsp.
n = len(s) - len(s.lstrip())
if n > 0 and s.strip():
s = '&nbsp;' * (n) + s[n:]
if not self.wrap:
# Convert all other blanks to &nbsp;
s = s.replace(' ', '&nbsp;')
s = s.replace('\n', '<br>') # The caller is responsible for newlines!
s = f'<font color="{color}">{s}</font>'
s = self.to_html(color, s)
if nodeLink:
url = nodeLink
for scheme in 'file', 'unl':
Expand All @@ -3288,6 +3271,20 @@ def put(self, s, color=None, tabName='Log', from_redirect=False, nodeLink=None):
w.moveCursor(MoveOperation.End)
sb.setSliderPosition(0) # Force the slider to the initial position.
w.repaint() # Slow, but essential.
#@+node:ekr.20220411085334.1: *5* LeoQtLog.to_html
def to_html(self, color: str, s: str) -> str:
"""Convert s to html."""
s = s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
# #884: Always convert leading blanks and tabs to &nbsp.
n = len(s) - len(s.lstrip())
if n > 0 and s.strip():
s = '&nbsp;' * (n) + s[n:]
if not self.wrap:
# Convert all other blanks to &nbsp;
s = s.replace(' ', '&nbsp;')
s = s.replace('\n', '<br>') # The caller is responsible for newlines!
s = f'<font color="{color}">{s}</font>'
return s
#@+node:ekr.20110605121601.18323: *4* LeoQtLog.putnl
def putnl(self, tabName='Log'):
"""Put a newline to the Qt log."""
Expand All @@ -3313,6 +3310,20 @@ def putnl(self, tabName='Log'):
w.moveCursor(MoveOperation.End)
sb.setSliderPosition(pos)
w.repaint() # Slow, but essential.
#@+node:ekr.20220411085427.1: *4* LeoQtLog.resolve_color
def resolve_color(self, color):
"""Resolve the given color name to an actual color name."""
c = self.c
# Note: g.actualColor does all color translation.
if color:
color = leoColor.getColor(color)
if not color:
# #788: First, fall back to 'log_black_color', not 'black.
color = c.config.getColor('log-black-color')
if not color:
# Should never be necessary.
color = 'black'
return color
#@+node:ekr.20150205181818.5: *4* LeoQtLog.scrollToEnd
def scrollToEnd(self, tabName='Log'):
"""Scroll the log to the end."""
Expand All @@ -3328,7 +3339,6 @@ def scrollToEnd(self, tabName='Log'):
w.moveCursor(MoveOperation.End)
sb.setSliderPosition(pos)
w.repaint() # Slow, but essential.
#@+node:ekr.20120913110135.10613: *3* LeoQtLog.putImage
#@+node:ekr.20110605121601.18324: *3* LeoQtLog.Tab
#@+node:ekr.20110605121601.18325: *4* LeoQtLog.clearTab
def clearTab(self, tabName, wrap='none'):
Expand Down
29 changes: 29 additions & 0 deletions leo/unittests/test_gui.py
Expand Up @@ -4,6 +4,7 @@
#@@first
"""Tests of gui base classes"""

### import textwrap
import time
from leo.core import leoGlobals as g
from leo.core.leoTest2 import LeoUnitTest, create_app
Expand Down Expand Up @@ -97,6 +98,34 @@ def test_qt_enums(self):
)
for ivar in table:
assert hasattr(QtCore.Qt, ivar), repr(ivar)
#@+node:ekr.20220411165627.1: *3* TestQtGui.test_create_html_links
def test_create_html_links(self):

c, p = self.c, self.c.p
# Create a test outline.
assert p == self.root_p
assert p.h == 'root'
p2 = p.insertAsLastChild()
p2.h = '@file test_file.py'
# Run the tests.
table = (
# python.
(None, 'File "test_file.py", line 5'),
# pylint.
(None, r'leo\unittest\test_file.py:1326:8: W0101: Unreachable code (unreachable)'),
# mypy...
(None, 'test_file.py:116: error: Function is missing a return type annotation [no-untyped-def]'),
(None, r'leo\core\test_file.py:116: note: Use "-> None" if function does not return a value'),
(True, 'Found 1 error in 1 file (checked 1 source file)'),
(True, 'mypy: done'),
# Random output.
(True, 'Hello world\n'),
)
for flag, s in table:
s = s.rstrip() + '\n'
result = c.frame.log.put_html_links(s)
expected_result = s if flag else None
self.assertEqual(result, expected_result)
#@-others
#@-others
#@-leo

0 comments on commit afbfce9

Please sign in to comment.