vibefpmgui.editor_issues
Issues in the editor's text: their squiggles, and the hover cards over them.
A part of vibefpmgui.code_editor.CodeEditor.
1"""Issues in the editor's text: their squiggles, and the hover cards over them. 2 3A part of `vibefpmgui.code_editor.CodeEditor`.""" 4 5from __future__ import annotations 6 7import html 8from bisect import bisect_left 9 10from qtpy.QtCore import QPoint, QPointF, QRect, Qt 11from qtpy.QtGui import ( 12 QColor, 13 QCursor, 14 QPainter, 15 QPainterPath, 16 QPalette, 17 QPen, 18 QTextCursor, 19) 20 21from . import hover_card, lint 22from .editor_shared import ( 23 DARK_UNDERLINE_COLORS, 24 MARKER_COLORS, 25 word_bounds, 26) 27 28 29class EditorIssuesMixin: 30 """The issues' squiggles and hover cards (a part of `vibefpmgui.code_editor.CodeEditor`).""" 31 32 def set_lint(self, issues: list[lint.LintIssue]): 33 self.lint_issues = issues 34 # The misspelled words in line order, for the squiggles (see squiggle_spans). 35 self._spelling = sorted((i for i in issues if i.severity == "spelling"), key=lambda i: (i.line, i.column)) 36 self._spelling_lines = [i.line for i in self._spelling] 37 self.update_extra_selections() 38 self.viewport().update() # (the spelling squiggles: see _paint_squiggles) 39 40 def replace_word(self, issue, word: str): 41 """A spelling suggestion: the misspelled word becomes ``word`` (if 42 it's still there).""" 43 span = self._lint_span(issue) 44 if span is None: 45 return 46 cursor = QTextCursor(self.document()) 47 cursor.setPosition(span[0]) 48 cursor.setPosition(span[1], QTextCursor.KeepAnchor) 49 if cursor.selectedText() != issue.word: 50 return # (the text changed since) 51 cursor.insertText(word) 52 self.setTextCursor(cursor) 53 54 def _lint_span(self, issue: lint.LintIssue) -> tuple[int, int] | None: 55 """Document positions to underline: the issue's text, only on its 56 first line (a syntax error can span the rest of the file); at least 57 one character so it can be seen and hovered.""" 58 block = self.document().findBlockByNumber(issue.line - 1) 59 if not block.isValid(): 60 return None 61 length = max(0, block.length() - 1) 62 start = min(max(issue.column - 1, 0), length) 63 end = min(issue.end_column - 1, length) if issue.end_line == issue.line else length 64 if end <= start: 65 start, end = (start, start + 1) if start < length else (max(0, length - 1), length) 66 if end <= start: 67 return None # an empty line 68 return block.position() + start, block.position() + end 69 70 def word_at(self, point: QPoint) -> tuple[int, int, str] | None: 71 """(1-based line, 0-based start column, word) of the name under a 72 viewport point, or None (whitespace, past the end of the line…).""" 73 cursor = self.cursorForPosition(point) 74 if abs(self.cursorRect(cursor).center().x() - point.x()) > self.fontMetrics().horizontalAdvance("M"): 75 return None # the nearest text is too far away 76 text = cursor.block().text() 77 bounds = word_bounds(text, cursor.positionInBlock()) 78 if bounds is None: 79 return None 80 start, end = bounds 81 return cursor.blockNumber() + 1, start, text[start:end] 82 83 def lint_card_html(self, issues, other_notes=()) -> str: 84 """fortitude's messages for ``issues`` (with fix links), plus any 85 other notes for the line (compiler, AI review) as plain paragraphs.""" 86 hover_card.set_text_color(self.palette().color(QPalette.Text)) 87 text = hover_card.lint_notes_html(issues, [self.lint_issues.index(i) for i in issues]) 88 for note in other_notes: 89 text += f"<p style='margin:4px 0 0 0'>{html.escape(note)}</p>" 90 return text 91 92 def _lint_visible(self, issue) -> bool: 93 """Is the start of an issue's underline within the visible text area 94 (not scrolled off to the side, as a long line's S001 usually is)?""" 95 span = self._lint_span(issue) 96 if span is None: 97 return False 98 cursor = QTextCursor(self.document()) 99 cursor.setPosition(span[0]) 100 x = self.cursorRect(cursor).left() 101 return 0 <= x < self.viewport().width() 102 103 def hover_key(self, point: QPoint): 104 """What the mouse is on, for deciding when the hover popup is stale: 105 the name (line, start column), or else the character position.""" 106 word = self.word_at(point) 107 return word[:2] if word else ("at", self.cursorForPosition(point).position()) 108 109 def viewportEvent(self, event): 110 if event.type() == event.Type.ToolTip: 111 if self.hover_popup.isVisible() and self.hover_popup.anchor == self.hover_key(event.pos()): 112 return True # already showing (and maybe scrolled): leave it 113 cursor = self.cursorForPosition(event.pos()) 114 pos = cursor.position() 115 notes = [i for i in self.lint_issues 116 if (span := self._lint_span(i)) and span[0] <= pos <= span[1]] 117 key = self.hover_key(event.pos()) 118 if not notes: 119 # An issue whose underline is scrolled out of view (a long 120 # line's S001 starts at column 101) shows anywhere on its line. 121 line = cursor.blockNumber() + 1 122 notes = [i for i in self.lint_issues if i.line == line and not self._lint_visible(i)] 123 if notes: 124 key = ("line", line) 125 if self.hover_popup.isVisible() and self.hover_popup.anchor == key: 126 return True 127 lint_html = self.lint_card_html(notes) if notes else "" 128 word = self.word_at(event.pos()) if self.hover_provider is not None else None 129 self._hover_word = word[:2] if word else None 130 if notes: 131 self.hover_popup.show_at(event.globalPos(), lint_html, key) 132 elif self.hover_popup.anchor != key: 133 self.hover_popup.hide() 134 if word: # the language server's answer is added when it arrives 135 self.hover_provider(word[0], word[1], event.globalPos(), lint_html) 136 return True 137 if event.type() == event.Type.Leave: 138 self.hover_popup.schedule_hide() 139 return super().viewportEvent(event) 140 141 def show_hover(self, line: int, column: int, global_pos: QPoint, text_html: str): 142 """Show a hover answer, unless the mouse has moved off that name.""" 143 if self._hover_word != (line, column): 144 return 145 word = self.word_at(self.viewport().mapFromGlobal(QCursor.pos())) 146 if word is None or word[:2] != (line, column): 147 return 148 self.hover_popup.show_at(global_pos, text_html, (line, column)) 149 150 def hide_popups(self): 151 self.hover_popup.hide() 152 self.completion_popup.hide() 153 self.signature_popup.hide() 154 155 def squiggle_spans(self, area: QRect) -> list[tuple[float, float, float, str]]: 156 """Where the misspelled words' squiggles go in ``area`` (viewport 157 coordinates): (left, right, y, color) per row a word is on.""" 158 issues = self._spelling 159 if not issues: 160 return [] 161 dark = self.palette().color(QPalette.Base).lightness() < 128 162 color = (DARK_UNDERLINE_COLORS if dark else MARKER_COLORS)["spelling"] 163 first = self.firstVisibleBlock() 164 top_line = first.blockNumber() + 1 165 offset = self.contentOffset() 166 out = [] 167 # (Only the words in view: from the first visible line, until one is below the area.) 168 for issue in issues[bisect_left(self._spelling_lines, top_line):]: 169 block = self.document().findBlockByNumber(issue.line - 1) 170 geometry = self.blockBoundingGeometry(block).translated(offset) 171 if geometry.top() > area.bottom(): 172 break 173 if geometry.bottom() < area.top() or not block.isVisible(): 174 continue 175 span = self._lint_span(issue) 176 layout = block.layout() 177 if span is None or layout is None: 178 continue 179 start, end = span[0] - block.position(), span[1] - block.position() 180 for n in range(layout.lineCount()): 181 row = layout.lineAt(n) 182 a, b = max(start, row.textStart()), min(end, row.textStart() + row.textLength()) 183 if a >= b: 184 continue 185 x1, x2 = row.cursorToX(a), row.cursorToX(b) 186 x1 = x1[0] if isinstance(x1, tuple) else x1 # (PySide: (x, position)) 187 x2 = x2[0] if isinstance(x2, tuple) else x2 188 y = geometry.top() + row.y() + row.ascent() + max(1.5, row.descent() * 0.45) 189 out.append((geometry.left() + x1, geometry.left() + x2, y, color)) 190 return out 191 192 def _paint_squiggles(self, area: QRect): 193 """Misspelled words' wavy underline, drawn here (thicker than the one 194 Qt draws for the other issues, which is easy to miss under a 195 comment's thin text, especially on a dark background).""" 196 spans = self.squiggle_spans(area) 197 if not spans: 198 return 199 painter = QPainter(self.viewport()) 200 painter.setRenderHint(QPainter.Antialiasing) 201 size = self.font().pointSizeF() if self.font().pointSizeF() > 0 else 12.0 202 width = max(1.4, size / 8) # pen width 203 height = max(1.6, size / 7) # half the wave's height 204 step = max(2.5, size / 4.5) # half a wave's length 205 for left, right, y, color in spans: 206 path = QPainterPath(QPointF(left, y)) 207 x, up = left, True 208 while x < right: 209 nxt = min(x + step, right) 210 path.quadTo(QPointF((x + nxt) / 2, y - height if up else y + height), QPointF(nxt, y)) 211 x, up = nxt, not up 212 pen = QPen(QColor(color), width) 213 pen.setCapStyle(Qt.RoundCap) 214 painter.setPen(pen) 215 painter.drawPath(path) 216 painter.end()
30class EditorIssuesMixin: 31 """The issues' squiggles and hover cards (a part of `vibefpmgui.code_editor.CodeEditor`).""" 32 33 def set_lint(self, issues: list[lint.LintIssue]): 34 self.lint_issues = issues 35 # The misspelled words in line order, for the squiggles (see squiggle_spans). 36 self._spelling = sorted((i for i in issues if i.severity == "spelling"), key=lambda i: (i.line, i.column)) 37 self._spelling_lines = [i.line for i in self._spelling] 38 self.update_extra_selections() 39 self.viewport().update() # (the spelling squiggles: see _paint_squiggles) 40 41 def replace_word(self, issue, word: str): 42 """A spelling suggestion: the misspelled word becomes ``word`` (if 43 it's still there).""" 44 span = self._lint_span(issue) 45 if span is None: 46 return 47 cursor = QTextCursor(self.document()) 48 cursor.setPosition(span[0]) 49 cursor.setPosition(span[1], QTextCursor.KeepAnchor) 50 if cursor.selectedText() != issue.word: 51 return # (the text changed since) 52 cursor.insertText(word) 53 self.setTextCursor(cursor) 54 55 def _lint_span(self, issue: lint.LintIssue) -> tuple[int, int] | None: 56 """Document positions to underline: the issue's text, only on its 57 first line (a syntax error can span the rest of the file); at least 58 one character so it can be seen and hovered.""" 59 block = self.document().findBlockByNumber(issue.line - 1) 60 if not block.isValid(): 61 return None 62 length = max(0, block.length() - 1) 63 start = min(max(issue.column - 1, 0), length) 64 end = min(issue.end_column - 1, length) if issue.end_line == issue.line else length 65 if end <= start: 66 start, end = (start, start + 1) if start < length else (max(0, length - 1), length) 67 if end <= start: 68 return None # an empty line 69 return block.position() + start, block.position() + end 70 71 def word_at(self, point: QPoint) -> tuple[int, int, str] | None: 72 """(1-based line, 0-based start column, word) of the name under a 73 viewport point, or None (whitespace, past the end of the line…).""" 74 cursor = self.cursorForPosition(point) 75 if abs(self.cursorRect(cursor).center().x() - point.x()) > self.fontMetrics().horizontalAdvance("M"): 76 return None # the nearest text is too far away 77 text = cursor.block().text() 78 bounds = word_bounds(text, cursor.positionInBlock()) 79 if bounds is None: 80 return None 81 start, end = bounds 82 return cursor.blockNumber() + 1, start, text[start:end] 83 84 def lint_card_html(self, issues, other_notes=()) -> str: 85 """fortitude's messages for ``issues`` (with fix links), plus any 86 other notes for the line (compiler, AI review) as plain paragraphs.""" 87 hover_card.set_text_color(self.palette().color(QPalette.Text)) 88 text = hover_card.lint_notes_html(issues, [self.lint_issues.index(i) for i in issues]) 89 for note in other_notes: 90 text += f"<p style='margin:4px 0 0 0'>{html.escape(note)}</p>" 91 return text 92 93 def _lint_visible(self, issue) -> bool: 94 """Is the start of an issue's underline within the visible text area 95 (not scrolled off to the side, as a long line's S001 usually is)?""" 96 span = self._lint_span(issue) 97 if span is None: 98 return False 99 cursor = QTextCursor(self.document()) 100 cursor.setPosition(span[0]) 101 x = self.cursorRect(cursor).left() 102 return 0 <= x < self.viewport().width() 103 104 def hover_key(self, point: QPoint): 105 """What the mouse is on, for deciding when the hover popup is stale: 106 the name (line, start column), or else the character position.""" 107 word = self.word_at(point) 108 return word[:2] if word else ("at", self.cursorForPosition(point).position()) 109 110 def viewportEvent(self, event): 111 if event.type() == event.Type.ToolTip: 112 if self.hover_popup.isVisible() and self.hover_popup.anchor == self.hover_key(event.pos()): 113 return True # already showing (and maybe scrolled): leave it 114 cursor = self.cursorForPosition(event.pos()) 115 pos = cursor.position() 116 notes = [i for i in self.lint_issues 117 if (span := self._lint_span(i)) and span[0] <= pos <= span[1]] 118 key = self.hover_key(event.pos()) 119 if not notes: 120 # An issue whose underline is scrolled out of view (a long 121 # line's S001 starts at column 101) shows anywhere on its line. 122 line = cursor.blockNumber() + 1 123 notes = [i for i in self.lint_issues if i.line == line and not self._lint_visible(i)] 124 if notes: 125 key = ("line", line) 126 if self.hover_popup.isVisible() and self.hover_popup.anchor == key: 127 return True 128 lint_html = self.lint_card_html(notes) if notes else "" 129 word = self.word_at(event.pos()) if self.hover_provider is not None else None 130 self._hover_word = word[:2] if word else None 131 if notes: 132 self.hover_popup.show_at(event.globalPos(), lint_html, key) 133 elif self.hover_popup.anchor != key: 134 self.hover_popup.hide() 135 if word: # the language server's answer is added when it arrives 136 self.hover_provider(word[0], word[1], event.globalPos(), lint_html) 137 return True 138 if event.type() == event.Type.Leave: 139 self.hover_popup.schedule_hide() 140 return super().viewportEvent(event) 141 142 def show_hover(self, line: int, column: int, global_pos: QPoint, text_html: str): 143 """Show a hover answer, unless the mouse has moved off that name.""" 144 if self._hover_word != (line, column): 145 return 146 word = self.word_at(self.viewport().mapFromGlobal(QCursor.pos())) 147 if word is None or word[:2] != (line, column): 148 return 149 self.hover_popup.show_at(global_pos, text_html, (line, column)) 150 151 def hide_popups(self): 152 self.hover_popup.hide() 153 self.completion_popup.hide() 154 self.signature_popup.hide() 155 156 def squiggle_spans(self, area: QRect) -> list[tuple[float, float, float, str]]: 157 """Where the misspelled words' squiggles go in ``area`` (viewport 158 coordinates): (left, right, y, color) per row a word is on.""" 159 issues = self._spelling 160 if not issues: 161 return [] 162 dark = self.palette().color(QPalette.Base).lightness() < 128 163 color = (DARK_UNDERLINE_COLORS if dark else MARKER_COLORS)["spelling"] 164 first = self.firstVisibleBlock() 165 top_line = first.blockNumber() + 1 166 offset = self.contentOffset() 167 out = [] 168 # (Only the words in view: from the first visible line, until one is below the area.) 169 for issue in issues[bisect_left(self._spelling_lines, top_line):]: 170 block = self.document().findBlockByNumber(issue.line - 1) 171 geometry = self.blockBoundingGeometry(block).translated(offset) 172 if geometry.top() > area.bottom(): 173 break 174 if geometry.bottom() < area.top() or not block.isVisible(): 175 continue 176 span = self._lint_span(issue) 177 layout = block.layout() 178 if span is None or layout is None: 179 continue 180 start, end = span[0] - block.position(), span[1] - block.position() 181 for n in range(layout.lineCount()): 182 row = layout.lineAt(n) 183 a, b = max(start, row.textStart()), min(end, row.textStart() + row.textLength()) 184 if a >= b: 185 continue 186 x1, x2 = row.cursorToX(a), row.cursorToX(b) 187 x1 = x1[0] if isinstance(x1, tuple) else x1 # (PySide: (x, position)) 188 x2 = x2[0] if isinstance(x2, tuple) else x2 189 y = geometry.top() + row.y() + row.ascent() + max(1.5, row.descent() * 0.45) 190 out.append((geometry.left() + x1, geometry.left() + x2, y, color)) 191 return out 192 193 def _paint_squiggles(self, area: QRect): 194 """Misspelled words' wavy underline, drawn here (thicker than the one 195 Qt draws for the other issues, which is easy to miss under a 196 comment's thin text, especially on a dark background).""" 197 spans = self.squiggle_spans(area) 198 if not spans: 199 return 200 painter = QPainter(self.viewport()) 201 painter.setRenderHint(QPainter.Antialiasing) 202 size = self.font().pointSizeF() if self.font().pointSizeF() > 0 else 12.0 203 width = max(1.4, size / 8) # pen width 204 height = max(1.6, size / 7) # half the wave's height 205 step = max(2.5, size / 4.5) # half a wave's length 206 for left, right, y, color in spans: 207 path = QPainterPath(QPointF(left, y)) 208 x, up = left, True 209 while x < right: 210 nxt = min(x + step, right) 211 path.quadTo(QPointF((x + nxt) / 2, y - height if up else y + height), QPointF(nxt, y)) 212 x, up = nxt, not up 213 pen = QPen(QColor(color), width) 214 pen.setCapStyle(Qt.RoundCap) 215 painter.setPen(pen) 216 painter.drawPath(path) 217 painter.end()
The issues' squiggles and hover cards (a part of vibefpmgui.code_editor.CodeEditor).
33 def set_lint(self, issues: list[lint.LintIssue]): 34 self.lint_issues = issues 35 # The misspelled words in line order, for the squiggles (see squiggle_spans). 36 self._spelling = sorted((i for i in issues if i.severity == "spelling"), key=lambda i: (i.line, i.column)) 37 self._spelling_lines = [i.line for i in self._spelling] 38 self.update_extra_selections() 39 self.viewport().update() # (the spelling squiggles: see _paint_squiggles)
41 def replace_word(self, issue, word: str): 42 """A spelling suggestion: the misspelled word becomes ``word`` (if 43 it's still there).""" 44 span = self._lint_span(issue) 45 if span is None: 46 return 47 cursor = QTextCursor(self.document()) 48 cursor.setPosition(span[0]) 49 cursor.setPosition(span[1], QTextCursor.KeepAnchor) 50 if cursor.selectedText() != issue.word: 51 return # (the text changed since) 52 cursor.insertText(word) 53 self.setTextCursor(cursor)
A spelling suggestion: the misspelled word becomes word (if
it's still there).
71 def word_at(self, point: QPoint) -> tuple[int, int, str] | None: 72 """(1-based line, 0-based start column, word) of the name under a 73 viewport point, or None (whitespace, past the end of the line…).""" 74 cursor = self.cursorForPosition(point) 75 if abs(self.cursorRect(cursor).center().x() - point.x()) > self.fontMetrics().horizontalAdvance("M"): 76 return None # the nearest text is too far away 77 text = cursor.block().text() 78 bounds = word_bounds(text, cursor.positionInBlock()) 79 if bounds is None: 80 return None 81 start, end = bounds 82 return cursor.blockNumber() + 1, start, text[start:end]
(1-based line, 0-based start column, word) of the name under a viewport point, or None (whitespace, past the end of the line…).
84 def lint_card_html(self, issues, other_notes=()) -> str: 85 """fortitude's messages for ``issues`` (with fix links), plus any 86 other notes for the line (compiler, AI review) as plain paragraphs.""" 87 hover_card.set_text_color(self.palette().color(QPalette.Text)) 88 text = hover_card.lint_notes_html(issues, [self.lint_issues.index(i) for i in issues]) 89 for note in other_notes: 90 text += f"<p style='margin:4px 0 0 0'>{html.escape(note)}</p>" 91 return text
fortitude's messages for issues (with fix links), plus any
other notes for the line (compiler, AI review) as plain paragraphs.
104 def hover_key(self, point: QPoint): 105 """What the mouse is on, for deciding when the hover popup is stale: 106 the name (line, start column), or else the character position.""" 107 word = self.word_at(point) 108 return word[:2] if word else ("at", self.cursorForPosition(point).position())
What the mouse is on, for deciding when the hover popup is stale: the name (line, start column), or else the character position.
110 def viewportEvent(self, event): 111 if event.type() == event.Type.ToolTip: 112 if self.hover_popup.isVisible() and self.hover_popup.anchor == self.hover_key(event.pos()): 113 return True # already showing (and maybe scrolled): leave it 114 cursor = self.cursorForPosition(event.pos()) 115 pos = cursor.position() 116 notes = [i for i in self.lint_issues 117 if (span := self._lint_span(i)) and span[0] <= pos <= span[1]] 118 key = self.hover_key(event.pos()) 119 if not notes: 120 # An issue whose underline is scrolled out of view (a long 121 # line's S001 starts at column 101) shows anywhere on its line. 122 line = cursor.blockNumber() + 1 123 notes = [i for i in self.lint_issues if i.line == line and not self._lint_visible(i)] 124 if notes: 125 key = ("line", line) 126 if self.hover_popup.isVisible() and self.hover_popup.anchor == key: 127 return True 128 lint_html = self.lint_card_html(notes) if notes else "" 129 word = self.word_at(event.pos()) if self.hover_provider is not None else None 130 self._hover_word = word[:2] if word else None 131 if notes: 132 self.hover_popup.show_at(event.globalPos(), lint_html, key) 133 elif self.hover_popup.anchor != key: 134 self.hover_popup.hide() 135 if word: # the language server's answer is added when it arrives 136 self.hover_provider(word[0], word[1], event.globalPos(), lint_html) 137 return True 138 if event.type() == event.Type.Leave: 139 self.hover_popup.schedule_hide() 140 return super().viewportEvent(event)
142 def show_hover(self, line: int, column: int, global_pos: QPoint, text_html: str): 143 """Show a hover answer, unless the mouse has moved off that name.""" 144 if self._hover_word != (line, column): 145 return 146 word = self.word_at(self.viewport().mapFromGlobal(QCursor.pos())) 147 if word is None or word[:2] != (line, column): 148 return 149 self.hover_popup.show_at(global_pos, text_html, (line, column))
Show a hover answer, unless the mouse has moved off that name.
156 def squiggle_spans(self, area: QRect) -> list[tuple[float, float, float, str]]: 157 """Where the misspelled words' squiggles go in ``area`` (viewport 158 coordinates): (left, right, y, color) per row a word is on.""" 159 issues = self._spelling 160 if not issues: 161 return [] 162 dark = self.palette().color(QPalette.Base).lightness() < 128 163 color = (DARK_UNDERLINE_COLORS if dark else MARKER_COLORS)["spelling"] 164 first = self.firstVisibleBlock() 165 top_line = first.blockNumber() + 1 166 offset = self.contentOffset() 167 out = [] 168 # (Only the words in view: from the first visible line, until one is below the area.) 169 for issue in issues[bisect_left(self._spelling_lines, top_line):]: 170 block = self.document().findBlockByNumber(issue.line - 1) 171 geometry = self.blockBoundingGeometry(block).translated(offset) 172 if geometry.top() > area.bottom(): 173 break 174 if geometry.bottom() < area.top() or not block.isVisible(): 175 continue 176 span = self._lint_span(issue) 177 layout = block.layout() 178 if span is None or layout is None: 179 continue 180 start, end = span[0] - block.position(), span[1] - block.position() 181 for n in range(layout.lineCount()): 182 row = layout.lineAt(n) 183 a, b = max(start, row.textStart()), min(end, row.textStart() + row.textLength()) 184 if a >= b: 185 continue 186 x1, x2 = row.cursorToX(a), row.cursorToX(b) 187 x1 = x1[0] if isinstance(x1, tuple) else x1 # (PySide: (x, position)) 188 x2 = x2[0] if isinstance(x2, tuple) else x2 189 y = geometry.top() + row.y() + row.ascent() + max(1.5, row.descent() * 0.45) 190 out.append((geometry.left() + x1, geometry.left() + x2, y, color)) 191 return out
Where the misspelled words' squiggles go in area (viewport
coordinates): (left, right, y, color) per row a word is on.