RE: Constrained text entry



Original message is over a month old:

http://mail.gnome.org/archives/gtk-list/2001-August/msg00224.html

(The issue was constraining the contents of a GtkEntry to be e.g. a number)

The GTK program below is a little study piece for nonmodal constrained text entry.  Imagine that the window has other stuff in it,
maybe a workspace or other controls which can be frobbed.  It isn't meant to be a modal box where you just verify once and the box
goes away.  I think UI-wise it is representative of a common kind of dialog box frequently developed by researchers, engineers, and
etc for internal or public tools.  I consider this "market segment" to be particularly important for a toolkit like GTK because,
frankly, they're one of the only demographics that gives a damn about GTK, Gnome, Free Software and etc.  Besides pure software
people, that's where most of the users seem to be.

The applet has three numerical entries representing the lengths of three sides of a triangle.  It has a drawing area to the left in
which it constructs a triangle of those dimensions.  This is a good example because there are two levels of validation: the
numerical conversion, and the triangle inequality constraint.  (also nonnegativity, so 3)  The boxes accept numbers of the form
1e5.  While the numerical grammar is simple, I don't want to make any assumptions as they did with the GIMP file|new box, which
verifies upon character entry.  That completely breaks if you have a grammar with members which are not connected by a sequence of
simple character edits.  (Eg, what do you do if you need a set of matched parenthesees?)

After buckets of code, (okay, maybe not buckets, but too many) I am getting close to satisfied with the behavior.  One problem is
that gtk_widget_grab_focus is broken.  (For reference, I'm using GTK 1.3 for windows.)  Like I mentioned before, I want to revert
the focus when I revert the value of the entry.  This I see as an important mental handshaking thing.  The alternative would be
maybe to beep.  Or both.  But I think that silently reverting the value is definitely unacceptable.  Also, if there are no
speakers, you still need handshaking.  And what good would it be to turn the field red but not change focus, if the user will
definitely have to change it back to submit after mentally changing gears?  My comment below about OpenInventor coexistence is
another reason for changing the focus.

I saw some web Q&A where some other confused programmers could not get gtk_widget_grab_focus to work in this context and other
people said it should work:

http://mail.gnome.org/archives/gnome-list/1999-November/msg00783.html

This guy thought it might be because focus-out was already executing:

http://mail.gnome.org/archives/gtk-list/2001-March/msg00050.html

If he's right, then it does in fact seem impossible to get this correctly implemented, unless there is some guru way to work around
this event behavior.

There definitely needs to be some reference manual comment on the GtkEditable page that describes "changed" and "activate" saying
to check out GtkWidget::"focus_out_event".  Presuming of course that that ends up catching all the salient cases.  Otherwise,
GtkEditable needs another signal, maybe called "changed_compound" or "changed_fully" that catches all the focus outs and whatever
else is appropriate.  This would be a first step toward giving this problem an obvious solution.

[this is Damon, responses to two of Eric's comments are below.]
> I do think this is an important usability issue, and I hope it
> does get addressed if we ever have a usability style guide.

There is a bunch of discussion constructing (what I understand to be) a Gnome style guide on usability gnome org   I've been trying
to tune in but most of the discussion is foriegn to me as I don't have a full (let alone recent 2.0 development) gnome installation
of any kind running on my own machines.  Without reading their work more carefully, I risk putting my foot in my mouth, but it
seems like a GTK style guide is prerequisite to what they are trying to do.  This seems bad.

> I couldn't see it covered in the FAQ, by the way - that only seems to
> cover conversion/validation of characters while typing, which is a
> slightly different issue (unless I missed this question).

You're right.  Not only is it slightly different, but really obvious to solve.  Tony Gale's terse response really pissed me off,
actually.  People who will only do discourse given a code example are one sort of people that create a lot of usability problems in
the free software world.  I am somewhat bitter that I had to construct such a basic thing from scratch just to explain it.  Real
industrial strength UI design is almost always done without code examples.  I could easily see that people feeling too lazy to
write an example for posting could have been a contributing factor to people not getting this constrained text thing right a long
time ago.  It's too easy to just look in the GIMP and say "Oh, look the GIMP File|New box floating point entries don't have an
easily generalizable UI, oh well that's okay, we just have floating point numbers so we'll just do that."  At least I'll be able to
post an example cleanly separated from my app so as to save others time if I get it right.

> Either show an application-modal dialog, or just revert the text, select
> it all, and set the application's focus back to it. So when the user
> switches back to the application they can sort it out.

In the app I was developing previously, my OpenInventor/Motif window seemed to know nothing of my Gtk window and vice versa.
There, since the inventor window was a view parameterized by the values in the GTK panel, it would be definitely unacceptable to
quietly revert the value and let the user wonder why the graphic does not seem to respond in the way he thought.  Note that in
practice, the reason focus left the GTK window was so that the mouse could trackball-drag the 3D model around.  Granted, this
particular circumstance does seem exotic and perhaps not representative of a large class of apps.

I tried to simulate this using GTK only, but it didn't work.  In my example, you can set SEPARATE_WINDOWS to 1 and get two disjoint
independent GTK windows.  But the focus_out event comes just the same, as one would hope.  I believe when I had a window from a
different toolkit, this behavior was not consistent.  I don't remember exactly what the behavior was, and I don't have access to
the code.  Even on my machine now, with SEPARATE_WINDOWS==0, the first GtkWindow::focus_out_event does not propagate to its child
widgets on the very first time.  Try typing a letter into a box and then focusing out.  (on windows alt+tab or clicking out either
way)  The letter stays the first time, though on all subsequent times it is reverted.  (on my machine anyway)  This is a bug or
feature.


[now this is all Eric Monsler]
> One UI feature you could use might be to change the font or color of the
> text being entered, until the user hits 'Enter' and the string is
> parsed, checked, and used.

It seems most common for "enter" in a modal dialog to be equivalent to clicking ok.  Tabing and clicking fields is reserved for
moving, and fields are automatically verified on moving as here.  It's certainly the Win/Mac convention anyway.  I think that would
ultimately be my preference in this situation, assuming we reoriented to a modal state of mind and the "do nothing" button was a
cancel/ok pair.  I think that the GTK API has encouraged a different meaning of the "activate" event and thus "enter", though.  In
the GIMP File|New box for example "enter" just verifies the field, despite the fact that in basically every Mac and Windows modal
dialog enter means OK.  More evidence that programmers often prefer what's easy to what makes sense to the user.

I can see "enter" in a nonmodal entry causing submission of just the item to make sense.  This is like what happens in a
spreadsheet when you edit a formula cell, though the analogy lacks a second entry which you might tab to.

> I don't like choices 1) or 3), because I commonly start to enter text
> into an entry, switch to another app or window to check something, and
> then switch back, losing my start at changes.  Those behaviors may be
> acceptable for trivial entry items, but would be very annoying for
> longer entries.  Even a filename entry could be very anooying to have
> lost the patially typed path.

Since filename boxes don't necessarily start with a valid item, a reasonable (and reasonably conventional) response to a bad
filename would be to not close the dialog, and to put the focus back in the entry box but not revert it.  You're right about the
size of the data being an important heuristic.  I can imagine auto reverting a math expression would be really annoying.  But,
especially if you grab the focus on an invalid nonmodal entry, what you do need to keep is the option to revert (escape key?  Not
sure for a mouse exit.  Double click out means revert?) so that you don't get wedged into a particular entry.

Thanks guys for your interest,


Jeff Henrikson





Here is the code example, nonmodal.cpp:

#include <gtk/gtk.h>
#include <gdk/gdk.h>
#include <assert.h>
#include <stdio.h>

#include <stdlib.h>
#include <string.h>
#include <math.h>
#define M_PI 3.1415926535897932384626433832795

#define SEPARATE_WINDOWS 0
// setting this nonzero makes two separate GtkWindows

typedef struct _ConstrainedEntry ConstrainedEntry;

GtkWidget* theTriangle=0;  // these 7 are the global "singletons"
ConstrainedEntry* sidea;
ConstrainedEntry* sideb;
ConstrainedEntry* sidec;
GtkWidget* theTriangleWindow=0;
GtkWidget* theEntryWindow=0;
GtkWidget* theEntryBox=0;

#define MAX_DOUBLE_STRING_LEN 40

typedef struct _ConstrainedEntry {
  char text[MAX_DOUBLE_STRING_LEN];
  GtkWidget* widget;
  float value;
  GtkWidget* triangle;
  bool (*isValid)(void*, double);  // (void* this, double newvalue)
} ConstrainedEntry;

/********************** verification functions ************************/

bool verify_double(char* s, double* d) {
  if(!s) return false;
  if(s[0]==0) return false;
  char* end=NULL;
  double newvalue = strtod(s,&end);
  if(end==NULL) return false;
  if(end<s+strlen(s))
    for(char* ch=end; ch[0]!=0; ch++)
      if(!((ch[0]==' ') || (ch[0]=='\t') || (ch[0]=='\n')|| (ch[0]=='\r')))
	return false;
  *d=newvalue;
  return true;
}

bool verify_nonnegative(double d) {
  return d>=0;
}

bool verify_triangle_inequality(double a, double b, double c) {
  assert((a>=0)&&(b>=0)&&(c>=0));
  return((a<b+c)&&(b<c+a)&&(c<a+b));
}

bool verify_triangle_entry(void* e, double newvalue)
{
  if(sidea!=NULL && sideb!=NULL && sidec!=NULL)
    return verify_triangle_inequality(e==sidea? newvalue: sidea->value,
				      e==sideb? newvalue: sideb->value,
				      e==sidec? newvalue: sidec->value);
  else
    return true;  // all entries aren't constructed yet
}

/************************** math functions **************************/

/** returns cosine of angle opposite c */
float law_of_cosines(float a, float b, float c) {
  float ret= (a*a + b*b - c*c)/(2*a*b);
  //printf("(%f,%f,%f->%f) ",a,b,c,ret);
  return ret;
}

// just for a printf, 2d euclidean distance:
float d(float x, float y) {return sqrt(x*x+y*y);}

float clamp(float x) {return (x<-1)? -1: (x<1)? x: 1;}

void compute_triangle(float a, float b, float c, float* xyz) {
  float cphia = law_of_cosines(c,b,a);
  float cphib = law_of_cosines(a,c,b);
  float cphic = law_of_cosines(a,b,c);
  assert(!((cphia>1)|| (cphib>1)|| (cphic>1)||   //should have been caught by
	   (cphia<-1)||(cphib<-1)||(cphic<-1))); //verify_triangle_entry
  float phia=acos(clamp(cphia));
  float phib=acos(clamp(cphib));
  float phic=acos(clamp(cphic));
  float thetaa=M_PI-(phib+phic)/2;
  float thetab=M_PI-(phic+phia)/2;
  float thetac=M_PI-(phia+phib)/2;
  float db=sin(phia/2)*c/sin(thetac);
  float da=sin(phib/2)*c/sin(thetac);
  float dc=sin(phib/2)*a/sin(thetaa);
  xyz[0]=-db; xyz[1]=0;
  xyz[2]=-dc*cos(thetaa); xyz[3]=dc*sin(thetaa);
  xyz[4]=-da*cos(thetac); xyz[5]=-da*sin(thetac);
  /*
  printf("[%f,%f,%f] ",a,b,c);
  printf("[%f,%f,%f]\n",
	 d(xyz[0]-xyz[2],xyz[1]-xyz[3]),
	 d(xyz[2]-xyz[4],xyz[3]-xyz[5]),
	 d(xyz[4]-xyz[0],xyz[5]-xyz[1]));
  fflush(stdout); */
}


/********************* BEGIN ConstrainedEntry operations ***************/

void constrained_entry_revert(ConstrainedEntry* e) {
  gtk_entry_set_text(GTK_ENTRY(e->widget),e->text);
}

void constrained_entry_verify(ConstrainedEntry* e) {
  gchar* text = gtk_entry_get_text(GTK_ENTRY(e->widget));
                             // do not mutate or free result
  if(strcmp(text,e->text)==0) return;  // do nothing if no input has changed.

  double newvalue;
  bool verified =
    verify_double(text,&newvalue)
    && verify_nonnegative(newvalue)
    && (e->isValid((void*)e,newvalue));
  if(!verified) {
    constrained_entry_revert(e);
    /****************
	      These below all seem to be broken.  Do I misunderstand?
    *****************/
    //gtk_widget_grab_focus(e->widget);
    //gtk_widget_draw_focus(e->widget);
    //gtk_widget_show(e->widget);
    //gtk_container_set_focus_child(GTK_CONTAINER(theWindow),e->widget);
    gtk_container_set_focus_child(GTK_CONTAINER(theEntryBox),e->widget);
    printf("<grab> ");  fflush(stdout);  // it really is running here
  } else {
    e->value = newvalue;
    printf("[%f taken as value] ",e->value);  fflush(stdout);

    assert(strlen(text)<=MAX_DOUBLE_STRING_LEN-1);
    e->text[MAX_DOUBLE_STRING_LEN-1]=0;
    strncpy(e->text, text, MAX_DOUBLE_STRING_LEN-1);
    // no particularly interesting error condition.

    constrained_entry_revert(e); // to truncate unused results
    //triangle_expose_event(NULL,NULL,NULL);
    //gtk_signal_emit_by_name(GTK_OBJECT(theTriangle),"expose_event");
    gtk_widget_queue_clear(theTriangle);
    //gtk_widget_queue_draw_area(theTriangle,0,0,100,100);
  }
}

gint constrained_entry_focus_out(void* junk1, void* junk2, void* user)
{
  ConstrainedEntry* e = (ConstrainedEntry*)user;
  //printf("(focus_out: %08x,%08x,%08x) ",junk1,junk2,user);
  constrained_entry_verify(e);
  return(FALSE);
}

gint constrained_entry_activate(void* junk1, void* user)
{
  ConstrainedEntry* e = (ConstrainedEntry*)user;
  //printf("(activate: %08x,%08x,%08x) ",junk1,junk2,user);
  constrained_entry_verify(e);
  return(FALSE);
}

gint leave_notify_handler(void* junk1, void* junk2, void* user)
{
  printf("leave_notify_event "); fflush(stdout);
  return TRUE;
}


ConstrainedEntry* new_constrained_entry(char* initial_input,
					bool (*_isValid) (void*, double)) {
  ConstrainedEntry* ret =
    (ConstrainedEntry*)g_malloc(sizeof(ConstrainedEntry));
  printf("(new_centry at %08x) ",ret);

  ret->isValid = _isValid;  // predicate for verification
                            // (eg, for testing triangle inequality)

  ret->widget = gtk_entry_new ();
  gtk_signal_connect (GTK_OBJECT (ret->widget), "focus_out_event",
		      GTK_SIGNAL_FUNC (constrained_entry_focus_out), ret);
  gtk_signal_connect (GTK_OBJECT (ret->widget), "activate",
		      GTK_SIGNAL_FUNC (constrained_entry_activate), ret);
  gtk_signal_connect (GTK_OBJECT (ret->widget), "leave_notify_event",
		      GTK_SIGNAL_FUNC (leave_notify_handler), ret);

  // check "can_focus" arg
  gpointer p=gtk_object_get_data(GTK_OBJECT(ret->widget), "can_focus");
  printf("(p=%08x",p);
  if(p!=0 && p != (gpointer)1 && p != (gpointer)-1) printf(",*p=%08x",p);
  printf(")"); fflush(stdout);

  /*
  gtk_object_set_data(GTK_OBJECT(ret->widget), "can_focus",(gpointer)-1);

  // check "can_focus" arg
  p=gtk_object_get_data(GTK_OBJECT(ret->widget), "can_focus");
  printf("(p=%08x",p);
  if(p!=0 && p != (gpointer)1 && p != (gpointer)-1) printf(",*p=%08x",p);
  printf(")"); fflush(stdout); */

  // put initial input into widget.  Make different than stored text
  // so that constrained_entry_verify parses it.
  ret->text[0]=0;
  gtk_entry_set_text(GTK_ENTRY(ret->widget),initial_input);

  ret->value=1;
  constrained_entry_verify(ret);
            // if initial_input bad, then 0 appears in box

  return ret;
}

/*********************** end ConstrainedEntry operations *************/

/************************** paint routine ****************************/

gboolean triangle_expose_event(GtkWidget *widget,
			       GdkEventExpose *event,
			       gpointer data)
{
  gdk_window_clear_area (widget->window,
                         event->area.x, event->area.y,
                         event->area.width, event->area.height);
  gdk_gc_set_clip_rectangle (widget->style->fg_gc[widget->state],
                             &event->area);
  float xyz[6] = {0,0,0,0,0,0};
  compute_triangle(sidea->value,sideb->value,sidec->value,xyz);
  gdk_draw_line(widget->window,
                widget->style->fg_gc[widget->state],
		(int)(50.5+10*xyz[0]), (int)(50.5-10*xyz[1]),
		(int)(50.5+10*xyz[2]), (int)(50.5-10*xyz[3]));
  gdk_draw_line(widget->window,
                widget->style->fg_gc[widget->state],
		(int)(50.5+10*xyz[2]), (int)(50.5-10*xyz[3]),
		(int)(50.5+10*xyz[4]), (int)(50.5-10*xyz[5]));
  gdk_draw_line(widget->window,
                widget->style->fg_gc[widget->state],
		(int)(50.5+10*xyz[4]), (int)(50.5-10*xyz[5]),
		(int)(50.5+10*xyz[0]), (int)(50.5-10*xyz[1]));
  gdk_gc_set_clip_rectangle (widget->style->fg_gc[widget->state],
                             NULL);
  return TRUE;
}



/************************* GTK box assembly ****************************/

GtkWidget* make_side_entry_box()
{

  gint homogeneous=0;
  gint spacing=0;
  gint expand=0;
  gint fill=0;
  gint padding=0;

  GtkWidget* box = gtk_vbox_new (homogeneous, spacing);
  GtkWidget* widget = 0;
  theEntryBox=box;

  ConstrainedEntry* centry;

  centry = new_constrained_entry("4",verify_triangle_entry);
  gtk_box_pack_start (GTK_BOX (box), centry->widget, expand, fill, padding);
  gtk_widget_show (centry->widget);
  sidea=centry;

  centry = new_constrained_entry("5",verify_triangle_entry);
  gtk_box_pack_start (GTK_BOX (box), centry->widget, expand, fill, padding);
  gtk_widget_show (centry->widget);
  sideb=centry;

  centry = new_constrained_entry("3",verify_triangle_entry);
  gtk_box_pack_start (GTK_BOX (box), centry->widget, expand, fill, padding);
  gtk_widget_show (centry->widget);
  sidec=centry;

  widget = gtk_button_new_with_label ("do nothing");
  gtk_box_pack_start (GTK_BOX (box), widget, expand, fill, padding);
  gtk_widget_show (widget);

  gtk_widget_show(box);
  return box;
}

GtkWidget* make_triangle_hbox()
{

  gint homogeneous=0;
  gint spacing=10;
  gint expand=0;
  gint fill=0;
  gint padding=0;

  /* and spacing settings */
  GtkWidget* box = gtk_hbox_new (homogeneous, spacing);
  GtkWidget* widget = 0;

  /* Create a series of buttons with the appropriate settings */
  widget = gtk_drawing_area_new ();
  gtk_drawing_area_size (GTK_DRAWING_AREA (widget), 100, 100);
  gtk_signal_connect (GTK_OBJECT (widget), "expose_event",
		      GTK_SIGNAL_FUNC (triangle_expose_event), NULL);
  gtk_box_pack_start (GTK_BOX (box), widget, expand, fill, padding);
  gtk_widget_show (widget);
  theTriangle = widget;

#if (!(SEPARATE_WINDOWS))
  widget = make_side_entry_box();
  gtk_box_pack_start (GTK_BOX (box), widget, expand, fill, padding);
  gtk_widget_show (widget);
#endif SEPARATE_WINDOWS

  gtk_widget_show(box);
  return box;
}

void focus_out() {
  printf("GtkWindow::focus_out_event "); fflush(stdout);
}

gint delete_event( GtkWidget *widget,
		   GdkEvent  *event,
		   gpointer   data )
{
  return(FALSE);
}

void destroy(GtkWidget *widget, gpointer data)
{
  gtk_main_quit();
}

void main(int argc, char** argv)
{
  GtkWidget *window;
  GtkWidget *box;

  gtk_init(&argc, &argv);
  //gdk_rgb_init();
  window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
  theTriangleWindow=window;
  gtk_signal_connect (GTK_OBJECT (window), "delete_event",
		      GTK_SIGNAL_FUNC (delete_event), NULL);
  gtk_signal_connect (GTK_OBJECT (window), "destroy",
		      GTK_SIGNAL_FUNC (destroy), NULL);
  gtk_signal_connect (GTK_OBJECT (window), "focus_out_event",
		      GTK_SIGNAL_FUNC (focus_out), NULL);
  gtk_container_set_border_width (GTK_CONTAINER (window), 10);
  box=make_triangle_hbox();
  gtk_container_add (GTK_CONTAINER (window), box);

#if SEPARATE_WINDOWS
  window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
  theEntryWindow=window;
  gtk_signal_connect (GTK_OBJECT (window), "delete_event",
		      GTK_SIGNAL_FUNC (delete_event), NULL);
  gtk_signal_connect (GTK_OBJECT (window), "destroy",
		      GTK_SIGNAL_FUNC (destroy), NULL);
  gtk_container_set_border_width (GTK_CONTAINER (window), 10);
  box = make_side_entry_box();
  gtk_container_add (GTK_CONTAINER (window), box);
#endif SEPARATE_WINDOWS

  gtk_widget_show(theTriangleWindow);
#if SEPARATE_WINDOWS
  gtk_widget_show(theEntryWindow);
#endif SEPARATE_WINDOWS

  gtk_main ();
}






[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]