Coverage for vibefpmgui/problems_panel.py: 93%

104 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The *Problems* tab: everything flagged in the file in the editor. 

2 

3The last build's compiler messages, fortitude's and fortls's issues, 

4misspelled words, gremlins (non-ASCII and invisible characters) and the AI 

5review's findings, in line order, drawn like 

6the Search tab's results (`vibefpmgui.result_tree`). A button per tool 

7(with its count) shows or hides its problems. Click a problem to go there. 

8""" 

9 

10from __future__ import annotations 

11 

12from pathlib import Path 

13 

14from qtpy.QtCore import Qt, Signal 

15from qtpy.QtWidgets import ( 

16 QApplication, 

17 QHBoxLayout, 

18 QMenu, 

19 QToolButton, 

20 QVBoxLayout, 

21 QWidget, 

22) 

23 

24from .result_tree import RESULT_ROLE, Problem, ResultTree 

25from .widgets import StatusLabel 

26 

27# (source, button label), in the order the buttons are shown. 

28SOURCES = [("build", "Build"), ("fortitude", "fortitude"), ("fortls", "fortls"), 

29 ("spelling", "Spelling"), ("gremlins", "Gremlins"), ("review", "AI review")] 

30HINT = "Open a file in the editor to see its problems here." 

31EXPLAINABLE = ("build", "fortitude", "fortls") # (sources Explain with AI is offered for) 

32 

33 

34class ProblemsPanel(QWidget): 

35 open_requested = Signal(str, int, int, int, int) # file, line, column, end line, end column 

36 explain_requested = Signal(object) # a Problem (right-click → Explain with AI) 

37 fix_requested = Signal(object, object) # its file's path and a Problem (right-click → Fix with AI…) 

38 apply_fix_requested = Signal(object, object) # its file's path and a Problem (right-click → Apply Fix) 

39 

40 def __init__(self, settings=None, parent=None): 

41 super().__init__(parent) 

42 self.settings = settings 

43 self.path: Path | None = None 

44 self.problems: list[Problem] = [] 

45 self.labels: tuple[str, str] = ("", "") # (folder, package) shown after the file's name 

46 self.title = StatusLabel(HINT) 

47 self.title.setContentsMargins(8, 4, 8, 2) 

48 self.buttons: dict[str, QToolButton] = {} 

49 header = QHBoxLayout() 

50 header.setContentsMargins(0, 0, 6, 0) 

51 header.setSpacing(4) 

52 header.addWidget(self.title, 1) 

53 for source, label in SOURCES: 

54 button = QToolButton() 

55 button.setCheckable(True) 

56 button.setAutoRaise(True) 

57 shown = settings.value(f"problems/{source}", True, type=bool) if settings is not None else True 

58 button.setChecked(shown) 

59 button.setToolTip(f"Show or hide {label}'s problems") 

60 button.toggled.connect(self._filter_changed) 

61 button.setProperty("label", label) 

62 self.buttons[source] = button 

63 header.addWidget(button) 

64 self.tree = ResultTree() 

65 self.tree.set_wrapping(True) # (long messages wrap: an AI review's can be a few sentences) 

66 self.tree.open_requested.connect(self.open_requested) 

67 self.tree.setContextMenuPolicy(Qt.CustomContextMenu) 

68 self.tree.customContextMenuRequested.connect(self._context_menu) 

69 layout = QVBoxLayout(self) 

70 layout.setContentsMargins(0, 0, 0, 0) 

71 layout.setSpacing(2) 

72 layout.addLayout(header) 

73 layout.addWidget(self.tree, 1) 

74 self.show_problems(None, []) 

75 

76 def _context_menu(self, pos): 

77 menu = self.context_menu(pos) 

78 if menu is not None: 

79 menu.exec(self.tree.viewport().mapToGlobal(pos)) 

80 menu.deleteLater() 

81 

82 def context_menu(self, pos) -> QMenu | None: 

83 """A problem's right-click menu (None: not on a problem).""" 

84 item = self.tree.itemAt(pos) 

85 data = item.data(0, RESULT_ROLE) if item is not None else None 

86 path, problem = data if data else (None, None) 

87 if not isinstance(problem, Problem): 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true

88 return None 

89 menu = QMenu(self) 

90 issue = problem.issue 

91 if issue is not None and issue.fixable: # (fortitude's own fix, as its hover card's Apply fix) 

92 label = f"Apply Fix: {issue.fix or issue.message}" + ("" if issue.fix_safe else " (unsafe — check it)") 

93 apply = menu.addAction(label, lambda: self.apply_fix_requested.emit(path, problem)) 

94 apply.setToolTip("Change the code as fortitude suggests (one Undo step)") 

95 menu.addSeparator() 

96 explain = menu.addAction("Explain with AI", lambda: self.explain_requested.emit(problem)) 

97 explain.setEnabled(problem.source in EXPLAINABLE) 

98 explain.setToolTip("Ask the local AI model what this means here, and how to fix it") 

99 fix = menu.addAction("Fix with AI…", lambda: self.fix_requested.emit(path, problem)) 

100 fix.setEnabled(problem.source in (*EXPLAINABLE, "review")) 

101 fix.setToolTip("Ask the local AI model for an edit that addresses this (you accept or reject " 

102 "each change)") 

103 menu.addAction("Copy Message", lambda: QApplication.clipboard().setText(problem.message)) 

104 menu.setToolTipsVisible(True) 

105 return menu 

106 

107 def _filter_changed(self): 

108 if self.settings is not None: 108 ↛ 111line 108 didn't jump to line 111 because the condition on line 108 was always true

109 for source, button in self.buttons.items(): 

110 self.settings.setValue(f"problems/{source}", button.isChecked()) 

111 self._refresh() 

112 

113 def show_problems(self, path: Path | None, problems: list[Problem], folder: str = "", package: str = ""): 

114 """The file in the editor and its problems (``path`` None: no file).""" 

115 self.path, self.problems, self.labels = path, list(problems), (folder, package) 

116 self._refresh() 

117 

118 def shown(self) -> list[Problem]: 

119 """The problems whose tool's button is on.""" 

120 return [p for p in self.problems if self.buttons[p.source].isChecked()] 

121 

122 def _refresh(self): 

123 counts = {source: 0 for source in self.buttons} 

124 for p in self.problems: 

125 counts[p.source] += 1 

126 for source, button in self.buttons.items(): 

127 button.setText(f"{button.property('label')} {counts[source]}") 

128 button.setVisible(counts[source] > 0 or not button.isChecked()) # (a hidden kind stays findable) 

129 if self.path is None: 

130 self.tree.clear_results() 

131 self.title.setText(HINT) 

132 return 

133 shown = self.shown() 

134 self.tree.set_problems(self.path, shown, *self.labels) 

135 total = len(self.problems) 

136 if not total: 

137 self.title.setText(f"No problems in <b>{self.path.name}</b>") 

138 else: 

139 hidden = total - len(shown) 

140 self.title.setText(f"<b>{total}</b> problem{'s' * (total != 1)}" 

141 + (f" ({hidden} hidden)" if hidden else ""))