Version 27 of Move cursor by display line in a text widget

Updated 2015-03-16 13:57:11 by AMG

Note: Tk 8.5 has this text widget capability by default, with no need for the complications below. However, until you are willing to upgrade to 8.5, ...


LV: Is the above comment _currently_ true? That is, should Tk 8.5a3 have this functionality? Because here's what I tried.

 package require Tk

 puts [info patchlevel]

 set t [text .t -width 20]
 .t insert 1.0 {These are the times which try men's souls.  The quick brown fox jumpted over the lazy dogs.}
 pack $t

Then, I ran the program. I'm told this is 8.5a3. I use the arrow down - which indeed takes me down 1 display line now. I then press <Control>A and I am taken, not before the word which, but before the word These.

Brian Theado: I duplicated this behavior with tclkit 8.5a2 and there is a simple fix. Currently the default bindings for <Home> and <End> are:

% bind Text <End>

   tk::TextSetCursor %W {insert lineend}

% bind Text <Home>

   tk::TextSetCursor %W {insert linestart}

To get the behavior you want (and I think this should be the default), try this:

 bind Text <End> {tk::TextSetCursor %W {insert display lineend}}
 bind Text <Home> {tk::TextSetCursor %W {insert display linestart}}

Also in your example, change the text widget to have "-wrap word".

LV: Brian, thanks! Is this something that I should report as a bug?

And for anyone reading along, if you also want this, don't forget to add:

bind Text <Control-a> {
    if {!$tk_strictMotif} {
        tk::TextSetCursor %W {insert display linestart}
    }
}
bind Text <Control-e> {
    if {!$tk_strictMotif} {
        tk::TextSetCursor %W {insert display lineend}
    }
}

If you add just these 4 bindings, then <shift>Home and <shift>End continue to move to the front/end of the paragraph.

Also, has anyone else playing with this text widget change noticed any pecularity? I'm seeing times when the cursor does not move properly. I'm just not certain how best to report the bug - I'll continue to work on it.

Here's my latest version of the above program:

package require Tk

puts [info patchlevel]

set t [text .t -width 20]
.t insert 1.0 "These are the times which try men's souls.  The quick brown fox jumped over the lazy dogs.\n\nWhen in the course of human events. "
pack $t

bind Text <End> {tk::TextSetCursor %W {insert display lineend}}
bind Text <Home> {tk::TextSetCursor %W {insert display linestart}}
bind Text <Control-a> {
    if {!$tk_strictMotif} {
        tk::TextSetCursor %W {insert display linestart}
    }
}
bind Text <Control-e> {
    if {!$tk_strictMotif} {
        tk::TextSetCursor %W {insert display lineend}
    }
}

What I am seeing is that if I click in the text widget, then use the cursor down, when the cursor gets to the lazy dogs line, the cursor won't move down to the next paragraph. If I click down into the next paragraph, then the cursor up won't move past the last display line.


Christian Heide Damm responded in comp.lang.tcl [L1 ] on how to move the cursor up and down visible lines instead of real text widget lines:

...namely binding up/down to something like this:

# Find the coordinates of the cursor and set the new height
# manually. Note: errors rounding off, since
# coordinates don't match character positions exactly.
lset {lines char} [split [$textWidget index insert] .]
lset {x y textWidth textHeight} [$textWidget bbox [$textWidget
index insert]]
lset {_ maxy _ _} [$textWidget bbox "end - 1 char"]
# When updating position, make sure y is within text boundaries
switch -- $upOrDown {
    up {
       set y [max [expr $y-$textHeight] 0]
    }
    down {
       set y [min [expr $y+$textHeight] $maxy]
    }
}
lset {newx newy width _} [$textWidget bbox [$textWidget index
@$x,$y]]
# Test on which side of the character
# we should position the cursor
if {$x>[expr $newx+$width/2]} {
  set x [expr $newx+$width+1]
}
set newIndex [$textWidget index @$x,$y]

Here is the above code in a function, with definitions for missing functions min/max and with missing function lset replaced with calls to scan. The code has also been modified to correctly handle more than a screenful of text. This code still lacks the feature found in the default tkTextUpDownLine where the original column is maintained across repeated operations even though some lines passed through don't have enough columns -- Brian Theado:

proc min args {lindex [lsort -real $args] 0}
proc max args {lindex [lsort -real $args] end}
proc moveUpDown {textWidget upOrDown} {
     # Make the insertion cursor visible so bbox doesn't return empty list
     $textWidget see insert

     # Find the coordinates of the cursor and set the new height
     # manually. Note: errors rounding off, since
     # coordinates don't match character positions exactly.
     scan [$textWidget index insert] {%d.%d} lines char
     scan [$textWidget bbox [$textWidget index insert]] {%d %d %d %d} x y textWidth textHeight
     scan [$textWidget bbox @[winfo width $textWidget],[winfo height $textWidget]] {%*d %d %*d %*d} maxy
     # When updating position, make sure y is within text boundaries
     switch -- $upOrDown {
       up {
           if {$y <= $textHeight} {
               $textWidget yview scroll -1 units
           } else {
               set y [max [expr $y-$textHeight] 0]
           }
       }
       down {
           if {$y >= $maxy} {
                $textWidget yview scroll 1 units
           } else {
                set y [min [expr $y+$textHeight] $maxy]
           }
       }
     }
     scan [$textWidget bbox [$textWidget index @$x,$y]] {%d %d %d %*d} newx newy width

     # Test on which side of the character
     # we should position the cursor
     if {$x>[expr $newx+$width/2]} {
       set x [expr $newx+$width+1]
     }
     return [$textWidget index @$x,$y]
}

# Replace the default Text widget bindings to try it out
bind Text <Up> {
    tkTextSetCursor %W [moveUpDown %W up]
}
bind Text <Down> {
    tkTextSetCursor %W [moveUpDown %W down]
}

# Selection via the keyboard should be re-bound for consistency (added 12/18/02)
bind Text <Shift-Up> {
    tkTextKeySelect %W [moveUpDown %W up]
}
bind Text <Shift-Down> {
    tkTextKeySelect %W [moveUpDown %W down]
}

I had to tweak Brian's code to make it work. I replaced tkTextSetCursor with tk::TextSetCursor and tkTextKeySelect with tk::TextKeySelect. I also noted the code doesn't work when the text in the widget is formatted with spacing or superscripting, etc. I added the following code:

set spacing 0
foreach tagName [$textWidget tag names insert] {
    set tagSpacing [$textWidget tag cget $tagName -spacing2]
    if {$tagSpacing ne {}} {
        set spacing [max $spacing $tagSpacing]
    }
}
incr textHeight $spacing

just after the scan which gets the value for the textHeight variable. This worked for my application, but it was around this point I realised how difficult it would be to do the job completely and how far out of my depth I was. :o}


Before I had found this great resource I had written my own function which actually replaces the ::tk::TextUpDownLine function and therefore 'all' the bindings should still work correctly. This includes the up/down and any special key with them, eg shift-up. I improved my code slightly using the above. It handles the variable ::tk::Priv which the above code doesn't. (Updated slightly 05/02/06) -- Lio:

# ::tk::TextUpDownLine --
# Returns the index of the character one line above or below the
# insertion cursor.  There are two tricky things here.  First,
# we want to maintain the original column across repeated operations,
# even though some lines that will get passed through don't have
# enough characters to cover the original column.  Second, we need
# to take into account wrapped lines.
#
# Arguments:
# w -          The text window in which the cursor is to move.
# n -          The number of lines to move: -1 for up one line,
#              +1 for down one line.

proc ::tk::TextUpDownLine {w n} {
     variable ::tk::Priv

     $w see insert
     scan [$w bbox insert] {%d %d %*d %d} xpos ypos height
     set border [expr {[$w cget -bd] + [$w cget -highlightthickness] + [$w cget -pady]}]
     set winHeight [expr {[winfo height $w] - 2 * $border}]

     set i [$w index insert]
     if {$Priv(prevPos) ne $i} {
        set Priv(pos) $xpos
     }

     if {(($n < 0) && ($ypos <= ($n * -1 * $height))) \
       || (($n > 0) && (($ypos + $n * $height) >= $winHeight))} {
        $w yview scroll $n units
        scan [$w bbox insert] {%*d %d %*d %d} ypos height
     }

     if {(($n < 0) && ($ypos > ($n * -1 * $height))) \
       || (($n > 0) && (($ypos + $n * $height) <= $winHeight))} {
        set new [$w index "@$Priv(pos),[expr {$ypos + $n * $height}]"]
        scan [$w bbox $new] {%d %d %d %*d} newx newy newwidth
        if { $newy==$ypos } {
           set new $i
        } elseif {$Priv(pos) > [expr {$newx+$newwidth/2}]} {
           set new [$w index "@[expr {$newx + $newwidth + 1}],$newy"]
        }
     } else {
        set new $i
     }

     set Priv(prevPos) $new
     return $new
}

LV: Anyone know why I might get this error when trying to use the above proc?

can't create procedure "::tk::TextUpDownLine": unknown namespace
    while executing
"proc ::tk::TextUpDownLine {w n} {
    variable ::tk::Priv

    $w see insert
    scan [$w bbox insert] {%d %d %*d %d} xpos ypos height
    set weight ..."
    (file "lwv.tcl" line 740)

Brian Theado: Older versions of Tk used function names like tkTextUpDownLine. It is only with newer versions (sometime in 8.4?) that the tk namespace was created and helper functions were all renamed from tk* to ::tk::* (i.e. tkTextUpDownLine to ::tk::TextUpDownLine)

LV: I also was told, over on the chat, that I needed to ensure that the

 package require Tk

statement had already occurred before the proc was declared. And that had not yet been done.

A suggestion was made to make the code:

if {[package vcompare [package require Tk 8.4] 8.4] == 0} then {
    proc ::tk::TextUpDownLine {w n} {
:
    }
}

so that the code only worked for 8.4, and didn't try to execute under 8.5, where the problem being solved will supposedly be fixed.

EKB: I found I had to hack the code above to handle a nonzero -spacing3 option. I replaced the "if" that calculates where to move this way: (Lio - note, this code was written before a few fixes to the above procedure, the changes were the addition of an extra if {$newy == $ypos} block)

    if { (($n < 0) && ($ypos > $height)) ||
    (($n > 0) && ([expr $ypos+$height] < [expr $weight*$height])) } {

        set new [$w index "@$Priv(pos),[expr $ypos+($n)*$height]"]
        scan [$w bbox $new] {%d %d %d %*d} newx newy newwidth
        # Check for this possibility with nonzero -spacing3:
        if {$newy == $ypos} {
            set new [$w index "@$Priv(pos),[
                expr $ypos+($n)*($height+[$w cget -spacing3])]"]
            scan [$w bbox $new] {%d %d %d %*d} newx newy newwidth
        }
        # Still the same? Then stick with current index
        if {$newy == $ypos} {
           set new $i
        } elseif {$Priv(pos) > [expr $newx+$newwidth/2]} {
           set new [$w index "@[expr $newx+$newwidth+1],$newy"]
        }
    } else {
        set new $i
    }

With the above TextUpDownLine in place, what code would I use to move the insert cursor before the first, or after the last, character on a visual line (i.e. a line as displayed within the box).

EKB Here's my somewhat inelegant solution (not using TextUpDownLine, but not conflicting with it either). The variable "w" holds the path to the text widget:

event add <<TextMoveStartLine>> <KeyPress-Home>
event add <<TextMoveEndLine>> <KeyPress-End>

bind $w <<TextMoveStartLine>> {
    set ypos [lindex [%W dlineinfo insert] 1]
    %W mark set insert [%W index "@0,$ypos"]
    break
}
bind $w <<TextMoveEndLine>> {
    scan [%W dlineinfo insert] {%%*d %%d %%d %%*d %%*d} ypos width
    %W mark set insert [%W index "@$width,$ypos"]
    break
}

de Better, you update to 8.5. Otherwise, more opaque code is needed, for a more robust version. I've (overwriting the text default bindings, for simplicity of demonstration):

bind Text <Home> {
    set ypos [lindex [%W dlineinfo insert] 1]
    %W mark set insert [%W index "@0,$ypos"]
    break
}
bind Text <End> {
    if {[%W get insert {insert +1c}] eq "\n"} {
        break
    }
    scan [%W dlineinfo insert] {%%*d %%d %%d %%*d %%*d} ypos width
    %W mark set insert [%W index "@$width,$ypos"]
    if {[%W get {insert +1c} {insert +2c}] eq "\n"} {
        %W mark set insert [%W index {insert +1c}]
    }
    break
}
bind Text <Shift-Home> {
    set ypos [lindex [%W dlineinfo insert] 1]
    tk::TextKeySelect %W [%W index "@0,$ypos"]
}
bind Text <Shift-End> {
    if {[%W get insert {insert +1c}] eq "\n"} {
        break
    }
    scan [%W dlineinfo insert] {%%*d %%d %%d %%*d %%*d} ypos width
    set newInd [%W index "@$width,$ypos"]
    if {[%W get [list $newInd +1c] [list $newInd +2c]] eq "\n"} {
        set newInd [list $newInd +1c]
    }
    tk::TextKeySelect %W $newInd
}

That fixes problems with the original <End> binding, if the text widget content containts ('hard') newlines (\n). Plus add make selecting with the Home/End Keys also work along display lines. That, together with the ::tk::TextUpDownLine from above seems to work.

Now to the house work: Make the 'hard' newlines visual distinguishable from 'soft' ones.