Version 42 of Dissecting a scrollbar

Updated 2007-05-11 12:38:46 by Duoas

Duoas There are already various treatments on handling scrollbars both in the Tk documentation and on the Wiki. However, this document aims to get really deep into making your scrollbars behave, such that the new scrollbar-user-wannabe can follow easily. I think this page is entirely warranted due both to my own experiences figuring-out the silly thing and for the obvious confusion others express here when first messing with it. I wonder how many give-up and conclude that the scrollbar is too ming-boggling a thing to make work the way they want.

If you too are a scrollbar expert, please feel free to add to this page any information you consider relevant. But remember not to jump over young minds.

Contents

  • Basics
  • Really small documents don't need a scrollbar.
  • Really big documents make for really tiny scrollbar handles.
  • Really big documents make things go slow.
  • I'd like to scroll past the end of my document.
  • What's this gap between my document and its scrollbar?
  • (more?)


Basics

If you don't already know, a scrollbar is one of the more unwieldly widgets in the GUI world --at least from the programmer's point of view. For refresher, a scrollbar is composed of multiple components.

http://home.comcast.net/~michaelthomasgreer/scrollbar-fig1.gif

It is entirely possible to have more than one arrow button at each end of the scrollbar, or none, but Tcl currently does not permit you to configure that. (You get what you get from your current widget set.)

The scrollbar set sub-command takes two floating-point numbers in the range 0.0 to 1.0, inclusive. The handle (or grip or "slider") in the above graphic is positioned at about "0.2 0.67". If you set the handle to range from 0.0 to 1.0, the scrollbar becomes useless and many scrollbar widgets will disable the arrow buttons and hide the handle to visually indicate that the scrollbar is not usable in any meaningful way. Some don't.

Some more visually pleasing scrollbars will also disable individual arrow buttons if the handle is at either end of the trough area (0.0 or 1.0).

An associated window displays a document (text, graphics, etc.). Typically the entire document cannot be viewed all at once, so only a portion is visible in the associated window's area.

Typically, you link a scrollbar and an associated widget with an appropriate command:

  scrollbar .hsb -orient horizontal -command {.txt xview}
  scrollbar .vsb -orient vertical   -command {.txt yview}
  text      .txt -wrap none -yscrollcommand {.vsb set} -xscrollcommand {.hsb set}

  pack .vsb -fill y -side right -expand yes
  pack .hsb -fill x -side bottom
  pack .txt -fill both -expand yes

So far so good. This stuff you already know.

Now for the messy stuff. The handle is supposed to represent the visible portion of the document, and the entire trough area the whole length of the document. This is the genius of the scrollbar widget. And our bane.

In almost all cases the issue is the interface between the document and the scrollbar. What we need to do is modify the commands that work between the scrollbar and the associated widget to something a little more intelligent than the simple example given above.



Really small documents don't need a scrollbar

If your scrollbar becomes disabled because the handle range was set to "0.0 1.0", some people consider that to be visual bloat left on the screen (particularly if it doesn't disable but makes the handle fill the whole space <shudder>). "If the scrollbar is unusable," they reason, "then why show it at all?"

See Scroll bars that appear only when needed for the full treatment.

The essential idea

The essential idea is to replace the associated widget's -yscrollcommand (or -xscrollcommand, as appropriate) with a new command that does the job of hiding or showing the scrollbar.

  proc sbset {sb first last} {
    if {($first <= 0.0) && ($last >= 1.0)} \
      then {
        # Since I used the pack manager above, I will here too.
        # Tell the pack manager not to display the scrollbar anymore.
        pack forget $sb
        } \
      else {
        # Restore the scrollbar to its prior status (visible and in the right spot).
        pack $sb
        }
    $sb set $first $last
    }

  .txt config -xscrollcommand {sbset .hsb} -yscrollcommand {sbset .vsb}

EDIT: Crud. I forgot that the pack manager is stupid. I'll have to fix the code both here and above to use the grid manager...



Really big documents make for really tiny scrollbar handles

This is the one that bugs me most. If I've got a nice 1000 pixel-high scrollbar, why must I grope for a four pixel-high handle? Granted, some systems allow you to fix the minimum size of the handle. For example, Windows XP scrollbars have this power. Unfortunately, there is no direct way to specify a minimum slider size in Tcl (without writing a platform-specific extension module).

However, you can fix this problem easily enough by once again modifying the commands linking the scrollbar and its associated window.

The essential idea

The essential idea is to replace the associated widget's -yscrollcommand (or -xscrollcommand, as appropriate) with a new command that does the job of keeping the scrollbar handle from shrinking beyond a "specific size".

By "specific size" you can mean in percentage of the range between 0.0 and 1.0, or you can mean number of pixels high.

Details

Since percentage is simple we'll start with that.

  proc sbset {sb minsize first last} {
    if {($last -$first) < $minsize} {
      set last [expr {$first +$minsize}]
      if {$last > 1.0} {
        set first [expr {1.0 -$minsize}]
        set last  1.0
        if {$first < 0.0} {set first 0.0}
        }
      }
    $sb set $first $last
    }

If you want to deal with actual pixel sizes, that can be done also. The tricky part is simply that we don't have access to a scrollbar's internals, so we'll have to guess at a couple things. In most cases errors should be minimal (three or four pixels off).

  pseudo-code (real code later):
  get length of scrollbar widget
  subtract sum of arrow buttons (usually they are square, so 2*[sb cget -width] is good enough)
  minsize = minsize-in-pixels / length

There is one other issue this code generates. If you remember, the trough area is portioned linearly from 0.0 to 1.0. If you forbid the slider from shrinking beyond a specific amount, the first location in the slider can never become large enough to scroll to the end of the document. This must also be remedied if you want the people using your program to like you.

The way to do it is to modify the command that the scrollbar sends to the document to adjust the first location back to an appropriate value. This involves a ratio:

            +------------+---------+---+
  scrollbar | trough1    |  slider | tr|ough2  
            +------------+---------+---+
                         ^first    ^last   <-- modified first and last
            +----------------+---+-----+
  document  | invisible      | v | inv |
            +----------------+---+-----+
                                 ^last     <-- actual first and last
                             ^first

The visible portion of the document is much smaller in relation to the invisible portion than the slider is to the trough (trough1 + trough2). We can create a scalar value (a number to multiply with) that changes the scrollbar ratio into the document ratio. Some simple mathematical properties:

  12.0 / 100 = 0.12
  0.12 * 100 = 12.0   -->   0.12 * (1.0 / 100) = 12.0

The difference is simply inverting the fraction. So, we can simply create a like 'inverter' fraction. For greater accuracy (every pixel counts here), I've chosen to work with the length of the trough against the length of the invisible portion of the document. (Computers get floating-point arithmetic wrong very easily.) The scalar value is:

  (1.0 - (document.last - document.first))  /  (1.0 - (scrollbar.last - scrollbar.first))

This gives me a number greater than or equal to 1.0 that I can multiply with scrollbar.first to get document.first. If the scrollbar is not modified, this value should be 1.0. All I have to do is keep the scalar for each scrollbar. (Since globals are evil, we'll hide the scalars off in our own namespace.)

Putting it all together gets us this:

  namespace eval ::sbset:: {
    variable scalar
    }

  proc scrollbar.set.cmd {sb min units first last} {
    if {$units eq {pixels}} {
      if {[string equal -length 1 [$sb cget -orient] v]} \
        then {set trough_length [expr {[winfo height $sb]}]} \
        else {set trough_length [expr {[winfo width  $sb]}]}
      set trough_length [expr {$trough_length -(2 *[$sb cget -width])}]
      set min [expr {double( $min ) /$trough_length}]
      }
    set range [expr {$last -$first}]

    set ::sbset::scalar($sb) 1.0

    if {$range < $min} {

      set ::sbset::scalar($sb) [expr {((1.0 -$range) /(1.0 -$min))}]

      set last [expr {$first +$min}]
      if {$last > 1.0} {
        set first [expr {1.0 -$min}]
        set last  1.0
        if {$first < 0.0} {set first 0.0}
        }
      }

    # This keeps the slider around even if it is too small.
    # That way the scrollbar doesn't automatically disable itself.
    # (Even if the slider can't be used the buttons still can.)
    # Of course, we only do this if we modified the slider size...
    if {($first == 0.0) && ($last == 1.0) && ($range != 1.0)} {
      set first 0.01  ;# Make sure both buttons are still usable
      set last  0.99
      }

    $sb set $first $last
    }

  proc widget.view.cmd {sb widget cmd0 cmd1 number {units units}} {
    # widget xview moveto fraction      <-- what we're interested in fixing
    # widget xview scroll number units  <-- don't mess with this
    if {$cmd1 eq {moveto}} \
      then {$widget $cmd0 moveto [expr {$number *$::sbset::scalar($sb)}]} \
      else {$widget $cmd0 $cmd1 $number $units}
    }

Notice how we have to pass along extra information in the commands, particularly in the widget.view.cmd: we give it the scrollbar name so that we can get the scalar value back out.

  .hsb config -command        {widget.view.cmd .hsb .txt xview}
  .vsb config -command        {widget.view.cmd .vsb .txt yview}
  .txt config -xscrollcommand {scrollbar.set.cmd .hsb 0.3 fractions}
  .txt config -yscrollcommand {scrollbar.set.cmd .vsb  50 pixels}

If you play around with this you'll see it is a very solid solution.



Really big documents make things go slow

TODO (with simple explanation and links to Mass-widget, etc.



I'd like to scroll past the end of my document

TODO (explain better and give examples)



What's this gap between my document and its scrollbar?

TODO (explain, Tk 8.5 obviates, Internal Scrollbars)



Commentary

Please post non-tutorial/explanative commentary here.