Showing posts with label current line. Show all posts
Showing posts with label current line. Show all posts

Saturday, August 16, 2014

OK, I am embarrassed (again)

I try not to make a blithering idiot of myself in public too often, but in this matter of highlighting the current line, I certainly have. Proudly presenting code snippets showing an entirely wrong way of doing it... I blush.

OK the right way to highlight some line is to present it to the editor (QTextEdit or QPlainTextEdit) as an "extra selection." An extra selection is a ... what? It isn't a class on its own. It's an object that you acquire as follows:

        self.current_line_sel = QTextEdit.ExtraSelection()

You may have many of these; you tell the editor about them by passing a list of them to its setExtraSelections() method. In my case, a list of one item, a selection marking the current line.

An extra selection object is basically a tuple comprising a QTextCursor and a QTextCharFormat. These are assignable properties, there are no get-set methods for them. So here's the whole initialization.

        self.current_line_fmt = QTextCharFormat()
        self.current_line_fmt.setProperty(QTextFormat.FullWidthSelection, True)
        self.current_line_fmt.setBackground(colors.get_current_line_brush())
        self.current_line_sel = QTextEdit.ExtraSelection()
        self.current_line_sel.format = QTextCharFormat(self.current_line_fmt)

The bit about setting the FullWidthSelection property of the QTextCharFormat is key; it ensures that the new background color will be painted the width of the editor's viewport regardless of the length of the current line. I wouldn't have know that without seeing it in the Code Editor example.

When the cursor-move signal arrives, it is only necessary to update the position of the cursor in the selection, and to re-assign the list of selections. Here is the abbreviated cursor-move code now.

    def _cursor_moved(self):
        tc = QTextCursor(self.Editor.textCursor()) # copy of cursor
...several lines snipped...
        # Change the extra selection to the current line.
        tc.clearSelection()
        self.current_line_sel.cursor = tc
        self.Editor.setExtraSelections([self.current_line_sel])

It is necessary to clear the selection from the cursor before using it. Without that step, my current-line highlight disappears as soon as a non-empty selection is made. Double-click a word and it is highlighted, and the current-line highlight goes out.

It is also necessary to re-assign the list of extra selections every time. It is not enough to update the existing selection's cursor. You have to make the editor aware of the change. Which kind of makes sense.

Anyway that's all there was to it. The code is 50% less than the method I displayed in three previous blog posts. And it has no effect on the undo/redo stack or the document's modified status. So it's all good now, except for my ego.

Friday, August 15, 2014

Current Line, Again

Today I reviewed the editview module, the widget that contains the text editor and also a row of widgets to display the document name, the scanned image filename, the logical page number, cursor line# and cursor column#, all in a row at the bottom.

I'd made this widget using Qt Creator, which means that all the UI setup is in a separate file automatically generated, and merged into my widget using multiple inheritance. (I described the process in this post.)

The complexity of this setup was, I strongly suspected, causing an annoying problem in which the Notes panel, which is actually a second QPlainTextEditor, never got a proper highlight on its selection. A selection in Notes was always 50% gray, which should only be the case when it was visible but did not have the keyboard focus. In any case, the verbose and opaque code output by Creator and pyuic5 irked me. So, after reviewing the existing (and pretty much working) code, I tackled the job of bringing the UI initialization into my own module. It was a lengthy but basically clerical task of copying chunks from the generated module, pasting them into my own, and editing them to simplify them and throw out the redundant bits. (Example redundancy: every lineEdit got its text set to a null string, which is the default anyway.)

After fixing a few syntax errors and tweaking a couple of margin values, it worked. And: the annoying problem with selection highlighting was gone! Click in the Notes panel and make a selection, it's highlighted a nice green, while the selection in the main editor goes to gray. Click in the Edit panel, vice versa. Nice.

But now I had to face a bug that's been there right along (I was in denial). Remember a long time ago, well, May 6, when I wrote about setting a highlight on the current line? I did it by setting the Text Block Format of the current line. Bad Idea, it turns out.

Changing the text block format was causing two problems. First, as soon as I moved the cursor, the document status changed to "modified" (the document name in the lower corner got bold and red). This is because QTextEdit considers a change of text block format to be an undoable action. Anything that goes on the undo stack makes the document modified.

This wouldn't matter a great deal once the document actually was modified, but it is annoying that just hitting a down-arrow turns on the "save me!" indicator. I'm sure a user would complain. OK, I would complain.

But it had another problem also: it was effectively killing control-z undo. If I typed some characters and immediately hit ^z, the undo worked. But if I moved the cursor to another line first and then keyed ^z, the only effect was that the cursor moved down one line. Control-z had become down-arrow. What?!?

I figured it out with a little reading about undo. When QTextEdit performs Undo, it says it leaves the cursor at the end of the changed text. So here's what happens: move the cursor to another line by an arrow key or by clicking. That stacks two changes of TextBlockFormat, one on the old current line (to a white background) and one on the new current line (to a purty pale yellow one). Probably both go in as one undo action, I don't know.

Anyway, now key ^z. What gets undone? The last change of text block format. And the cursor is moved to the end of the restored area, which means, the start of the next line in the document. That's an edit cursor position change, which goes through my code to change the text block format of the new current line, stacking another undo action. Another ^z undoes that and moves down a line. Etc.

I now have to go back to trying to highlight the current line using "extra selections". I tried that initially and something didn't work, I forget what. Well... maƱana.

Tuesday, April 15, 2014

Current Line Revisited

A few days ago I described my progress on editview, but just today I stumbled on a big improvement. Here's how it looks now.

If you click through you find that's a quite large image. The reason is, it's from a retina macbook so what looks like quite a modest window, when captured, comes out 1500px wide. Here are the improvements from the prior version.

  • The current-line highlight now extends the full width of the window. Before it was only as long as the text on that line.
  • Scanno highlighting (the lilac highlights) is implemented. You can load a file of common OCR errors and they are marked wherever they appear.
  • Spellcheck highlighting (wiggly magenta underlines) is implemented, including alternate dictionaries. Note the line with <span lang='fr_FR'>; those words get checked against the french dictionary instead of the default one.

Pretty much all that remains is to finish an automated unit test of these features. I have one simple unit test driver now that uses QTest to automate a number of keystrokes, but I need to also automate exercising a pop-up context menu. That'll be an adventure I'm sure.

In the previous post I kvetched about how, although a QTextBlock has a format (QTextBlockFormat), you could only interrogate it, and modifying it didn't change the format. As a result, what I expected would be a simple way to set a current-line highlight, by setting the background brush of the current text block, didn't work.

Then today, browsing around the QTextCursor documentation, what should my eye fall upon but a setBlockFormat method! You can ask a QTextBlock for its format, but in order to set it, you have to aim a QTextCursor at that block, and then tell the cursor to set the block's format.

Bizarre.

Well, at any rate, not how I'd have designed it. But I didn't, so...

So I realized that my previous method of highlighting the current line using the extraSelections mechanism was over-complicated. I changed the logic to set a background brush on the current block. The cursor-moved logic now reads as follows:

Note: The following is still not the correct way to set a current-line highlight. Do not emulate this code. See this post for the problem with it and a later post for the correct approach.

    def _cursor_moved(self):
        tc = QTextCursor(self.Editor.textCursor())
        self.ColNumber.setText(str(tc.positionInBlock()))
        tb = tc.block()
        if tb == self.last_text_block:
            return # still on same line, nothing more to do
        # Fill in line-number widget, line #s are origin-1
        self.LineNumber.setText(str(tb.blockNumber()+1))
        # Fill in the image name and folio widgets
        pn = self.page_model.page_index(tc.position())
        if pn is not None : # the page model has info on this position
            self.ImageFilename.setText(self.page_model.filename(pn))
            self.Folio.setText(self.page_model.folio_string(pn))
        else: # no image data, or cursor is above page 1
            self.ImageFilename.setText('')
            self.Folio.setText('')
        # clear any highlight on the previous current line
        self.last_cursor.setBlockFormat(self.normal_line_fmt)
        # remember this new current line
        self.last_cursor = tc
        self.last_text_block = tb
        # and set its highlight
        tc.setBlockFormat(self.current_line_fmt)

Wednesday, April 9, 2014

Which Line Is It, Anyway?

The editview module is getting pretty complete. The only missing function is the dreaded syntax-highlighter to highlight scannos or spelling errors. Here's what it looks like now.

Today I added the code to highlight the current line. That's why one line has a sort of pale-lemon background. In V1, there was no current line highlight, and it was quite easy to lose sight of the cursor, and have to rattle the arrow keys to find it. (The string shown in dark gray is selected text and is actually bright yellow; the Grab utility did something to the colors.)

Qt's method of doing this was surprising to me.

In a QPlainTextEdit, there is a 1:1 correspondence between text blocks and logical lines. Each line of text is in one QTextBlock. Now, QTextBlock has a property blockFormat which is a QTextBlockFormat, which is itself a QTextCharFormat derivative, i.e. it can be used to set the font, color, background brush and so on. So when I started looking at how to make the current line a different color, I saw this and supposed it would be a matter of, each time the cursor moved:

  • Get the text block containing the cursor, a single method call,
  • Clear the background brush of the previous line's text block,
  • Set the current text block's blockFormat to a different background brush

But in fact QTextBlock lacks anything like a setBlockFormat, so the property is read-only. And setting the background property of the returned QTextBlockFormat object was accepted but had no visible effect.

Sigh, back to the googles to find a number of places in the Qt docs, stackoverflow and the like, where the question is raised and answered.

QPlainTextEdit supports a property extraSelections, which is a list of QTextEdit::ExtraSelection objects. This is the first and I think only time I've seen a class documented as child of another class. And it's a weird little class; it has no methods (not even a constructor), just two properties, cursor and format. So it's basically the C++ version of a python tuple.

What you do is, you get a QTextCursor to select the entire line, and you build an ExtraSelection object with that cursor and the QTextCharFormat you want to use, and assign that to the edit object's list of extra selections. This is a lot of mechanism to just highlight one line. Apparently the intent is to support an IDE that, for example, wants to put a different color on each line set as a breakpoint, or such.

Note: The following is not the correct way to set a current-line highlight. Do not emulate this code. See this post for the problem with it and a later post for the correct approach.

Anyway for the curious, this is the code that executes every bloody time the cursor moves:

    def _cursor_moved(self):
        tc = QTextCursor(self.Editor.textCursor())
        self.ColNumber.setText(str(tc.positionInBlock()))
        tb = tc.block()
        ln = tb.blockNumber()+1 # block #s are origin-0, line #s origin-1
        if ln != self.last_line_number:
            self.last_line_number = ln
            self.LineNumber.setText(str(ln))
            tc.movePosition(QTextCursor.EndOfBlock)
            tc.movePosition(QTextCursor.StartOfBlock,QTextCursor.KeepAnchor)
            self.current_line_thing.cursor = tc
            self.Editor.setExtraSelections([self.current_line_thing])
            pn = self.page_model.page_index(tc.position())
            if pn is not None : # the page model has info on this position
                self.ImageFilename.setText(self.page_model.filename(pn))
                self.Folio.setText(self.page_model.folio_string(pn))
            else: # no image data, or positioned above page 1
                self.ImageFilename.setText('')
                self.Folio.setText('')

In sequence this does as follows:

  • Get a copy of the current edit cursor. A copy because we may mess with it later.
  • Set the column number in the column number widget.
  • Get the QTextBlock containing the cursor's position property (note 1 below).
  • Get the line number it represents.
  • If this block is a change from before (note 2):
    • Set the line number in the line number widget.
    • Make the cursor selection be the entire line ("click" at the end, "drag" to the front)
    • Set that cursor in a single ExtraSelection object we keep handy.
    • Assign that object as a list of one item to the editor's extra selections.
    • Get the filename of the current image file, if any; and if there is one, display it and the logical folio for that page in the image and folio widgets.

Note 1: If there's no selection, a text cursor's position is just where the cursor is. But if the user has made a selection, the position property might be at either end of it. Drag from up-left toward down-right and the position is the end of the selection. Drag the other way, it's at the start. Drag a multi-line selection that starts and ends in mid-line. One of the lines will have the faint current-line highlight: the top line if you dragged up, the bottom line if you dragged down. I don't think anyone will notice, or care if they do. I could add code to set the current line on min(tc.position(),tc.anchor())—but I won't.

Note 2: Initially, there was no "if ln != self.last_line_number" test; everything was done every time the cursor moved. And actually performance was fine. But I just could not stand the idea of all that redundant fussing about happening when it didn't have to.