Sunday, December 9, 2012

Font Rendering with Freetype and Libpng (Part 2)

Hi there,

this is part two of rendering a font with freetype into a PNG image. In this second part I will show how to cross-compile the program we created in the first part for Windows x64.
I'm going to use the mingw-w64 project which makes it very easy. Just install the mingw-w64 package.
$ sudo aptitude install mingw-w64
After successful installation you can use the mingw-w64 toolchain:
$ x86_64-w64-mingw32-gcc --version
x86_64-w64-mingw32-gcc (GCC) 4.6.3
Copyright (C) 2011 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Now we just have to use this to compile freetype, zlib and libpng. Unfortunately the average build system is not really prepared for cross-compilation and even if it is it's not necessarily built with mingw-w64 in mind. Therefore it is usually necessary to fiddle around with various parameters to configure and make.

Cross-Compiling the Libraries

$ cd freetype-2.4.10
$ make clean
$ ./configure --disable-shared CC=x86_64-w64-mingw32-gcc RANLIB=x86_64-w64-mingw32-ranlib AR=x86_64-w64-mingw32-ar --host=x86_64
$ make
$ cp objs/.libs/libfreetype.a ../font-render/libs/win64/

$ cd ../zlib-1.2.7
$ make clean
$ ./configure --static
$ make CC=x86_64-w64-mingw32-gcc RANLIB=x86_64-w64-mingw32-ranlib AR=x86_64-w64-mingw32-ar
$ cp libz.a ../font-render/libs/win64/

$ cd ../libpng-1.5.13
$ make clean
$ ./configure --disable-shared CC=x86_64-w64-mingw32-gcc RANLIB=x86_64-w64-mingw32-ranlib AR=x86_64-w64-mingw32-ar --host=x86_64 CFLAGS=-L../zlib-1.2.7
$ make CFLAGS='-L../zlib-1.2.7 -I../zlib-1.2.7'
$ cp .libs/libpng15.a ../font-render/libs/win64/
Now you should be able to compile the program for Windows x64:
$ cd ../font-render
$ x86_64-w64-mingw32-gcc -Wall -pedantic -std=c99 -Iinclude -Llibs/win64 render-text.c -o render-text.exe -lfreetype -lpng15 -lz -lm
And that's it.

To keep it short and sweet, I will postpone the rendering of multiple characters to a third part. Don't worry, I will post it soon.

As always, if you have any comments, critique, suggestions or just want to tell me how much I suck please use the comment form below. I would appreciate your feedback.

Font Rendering with Freetype and Libpng (Part 1)

Namaste,

long time, no see. Not that anyone is reading this stuff, but I say it anyway: I'm still alive and this blog is definitely not dead :-)

So, without further ado, let's talk about some codez.
Today I want to show a simple example of rendering a character string using freetype and libpng. The codez are also available on github: https://github.com/sendtehcodez/font-rendering/tree/part1. Of course, it's free to use for any purpose and hereby released under the terms of the WTFPL license. Yay!

Note that I will use gcc for this exercise and I will compile this on (X)ubuntu linux. For bonus points I'm going to cross-compile it for Windows as well, but I'm going to cheat and use mingw-w64 which makes it so easy that even I can manage it. It's going to be glorious.
And, just so you know, because I'm apparently a crazy masochist I'm actually doing this in a virtual machine running on Windows 7. But that's just how I roll.

So, enough drivel, let's code the codez. My project directory structure is going to look like this:
font-render/         # the project root directory
    include/         # I will put include files from libs here
    libs/
        linux64/     # this will contain the libs for linux64
        win64/       # here we put the win64 libs
    render-text.c    # our codez
    arial.ttf        # just a random font I copied for testing so I 
                     # don't have to specify a long path to 
                     # /usr/share/fonts/... every time
To start off, let's create a simple stub for text-render.c:
#include <stdio.h>

int main(int argc, char **argv) {
  puts("booyah");
} 
Exciting, isn't it? I'm going to compile this:
$ gcc -Wall -pedantic -std=c99 render-text.c -o render-text
$ ./render-text
booyah
OK, so it works. I like repeatable builds and standalone executables and freetype and libpng don't ship with Windows anyway so I'm going to download the source and build a nice static library to link into my executable.
$ wget http://download.savannah.gnu.org/releases/freetype/freetype-2.4.10.tar.bz2
$ tar xjf freetype-2.4.10.tar.bz2
$ cd freetype-2.4.10
$ ./configure --disable-shared && make
$ cp objs/.libs/libfreetype.a ../font-render/libs/linux32/
$ cp -R include/* ../font-render/include/
So far so good. Let's actually load a font file now. To make life easier let's quickly implement some simple argument handling:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct {
  char *font_file;
  char *text;
  int size;
  int anti_alias;
} conf_t;

char *parse_args(int argc, char **argv, conf_t *conf) {
  memset(conf, 0, sizeof(conf_t));
  conf->size = 72;
  if (argc < 2) {
    return "missing font file argument";
  } else if (argc < 3) {
    return "missing text argument";
  }
  conf->font_file = argv[1];
  conf->text = argv[2];
  if (argc > 3) {
    conf->size = atoi(argv[3]);
  }
  if (argc > 4) {
    conf->anti_alias = strcmp(argv[4], "yes") == 0;
  }
  return NULL;
}

int main(int argc, char **argv) {
  conf_t conf;
  char *conf_err = parse_args(argc, argv, &conf);
  if (conf_err != NULL) {
    printf("Error: %s\n", conf_err);
    puts("Usage: render-text <font> <text> [<size> [<anti-alias>]]");
    puts("Example: render-text myfont.ttf foobar 24 yes");
    puts("         render-text myfont.ttf lorem");
    return 1;
  }

  printf("font: %s, text: %s, size: %d, anti-alias: %s\n",
         conf.font_file,
         conf.text,
         conf.size,
         conf.anti_alias ? "yes" : "no");

  return 0;
}
Recompiling this and testing it out it should work fine.
$ ./render-text 
Error: missing font file argument
Usage: render-text <font> <text> [<size> [<anti-alias>]]
Example: render-text myfont.ttf foobar 24 yes
         render-text myfont.ttf lorem
$ ./render-text foo.ttf blabla
font: foo.ttf, text: blabla, size: 72, anti-alias: no


Rendering a Glyph with Freetype

So let's add freetype into the mix. Add this to the includes:
#include <ft2build.h>
#include FT_FREETYPE_H
As a first step, let's just render the first character of the text. To keep it simple I put everything into a single function. This will be refactored when we render more than a single character. For error handling I simply return NULL for success and an error string on failure.
char *render_glyph(FT_Face *face, conf_t conf) {
  FT_Library ft;
  FT_Error err;

  err = FT_Init_FreeType(&ft);
  if (err) return "freetype init error";

  err = FT_New_Face(ft, conf.font_file, 0, face);
  if (err == FT_Err_Unknown_File_Format)
    return "unknown font file format";
  else if (err)
    return "error reading font file";

  err = FT_Set_Pixel_Sizes(*face, 0, conf.size);
  if (err) return "error setting font size";

  FT_UInt index = FT_Get_Char_Index(*face, *conf.text);
  if (index == 0) return "no glyph found for char";

  err = FT_Load_Glyph(*face, index, FT_LOAD_DEFAULT);
  if (err) return "error loading glyph";

  err = FT_Render_Glyph((*face)->glyph, conf.anti_alias ? 
                        FT_RENDER_MODE_NORMAL : 
                        FT_RENDER_MODE_MONO);
  if (err) return "error rendering glyph";

  return NULL;
}

Now we can use render_glyph in the main function, just add this at the end (before the return of course):
  FT_Face face;
  char *ft_err = render_glyph(&face, conf);
  if (ft_err != NULL) {
    printf("freetype error: %s\n", ft_err);
    return 2;
  }

  printf("bitmap rows: %d, width: %d\n", 
         face->glyph->bitmap.rows,
         face->glyph->bitmap.width);

When recompiling make sure to give it the correct paths for includes and libs. For example:
$ gcc -Wall -pedantic -std=c99 -Iinclude \
  render-text.c -Llibs/linux64 -lfreetype -o render-text

$ ./render-text arial.ttf blabla
font: arial.ttf, text: blabla, size: 72, anti-alias: no
bitmap rows: 53, width: 33

$ ./render-text arial.ttf blabla 200
font: arial.ttf, text: blabla, size: 200, anti-alias: no
bitmap rows: 145, width: 90

Alrighty. Looks good so far. Now we just need to write a PNG with the bitmap data.

Writing the PNG

Let's build zlib:
$ wget http://prdownloads.sourceforge.net/libpng/zlib-1.2.7.tar.gz?download -O zlib-1.2.7.tar.gz
$ tar xzf zlib-1.2.7.tar.gz
$ cd zlib-1.2.7
$ ./configure --static && make
$ cp libz.a ../font-render/libs/linux64/
$ cp zlib.h ../font-render/include/
And then libpng:
$ wget http://prdownloads.sourceforge.net/libpng/libpng-1.5.13.tar.gz?download -O libpng-1.5.13.tar.gz
$ tar xzf libpng-1.5.13.tar.gz
$ cd libpng-1.5.13
$ ./configure CFLAGS='-L../zlib-1.2.7' --disable-shared
$ make CFLAGS='-I../zlib-1.2.7 -L../zlib-1.2.7'
$ cp .libs/libpng15.a ../font-render/libs/linux64/
$ cp png*.h ../font-render/include/
Now we're ready to use libpng. Behold, the glorious render_png function:
char *render_png(FT_Face face, char *out, int aa) {
  FILE *f = fopen(out, "wb");
  if (!f) return "failed to open output file";

  png_structp png_out = png_create_write_struct(
    PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
  if (!png_out) return "failed to create png write struct";
  png_infop png_info = png_create_info_struct(png_out);
  if (!png_info)
    return "failed to create png info struct";

  if (setjmp(png_jmpbuf(png_out)))
    return "png init io error";
  png_init_io(png_out, f);

  if (setjmp(png_jmpbuf(png_out)))
    return "IHDR write error";
  png_set_IHDR(png_out,
               png_info,
               face->glyph->bitmap.width,
               face->glyph->bitmap.rows,
               aa ? 8 : 1,
               PNG_COLOR_TYPE_GRAY,
               PNG_INTERLACE_NONE,
               PNG_COMPRESSION_TYPE_DEFAULT,
               PNG_FILTER_TYPE_DEFAULT);
  png_write_info(png_out, png_info);

  if (setjmp(png_jmpbuf(png_out)))
    return "png write error";

  for (int i = 0; i < face->glyph->bitmap.rows; ++i) {
    const unsigned char *rowptr = face->glyph->bitmap.buffer +
      (face->glyph->bitmap.pitch * i);
    png_write_row(png_out, rowptr);
  }

  if (setjmp(png_jmpbuf(png_out)))
    return "png end error";
  png_write_end(png_out, NULL);

  fclose(f);
  return NULL;
}

Note that this is not properly cleaning up in case of errors because I was too lazy to do it The Right WayTM but for this program it doesn't really matter anyway. I'm also using the same lazy error handling technique as before. Oh, and don't forget to include the "png.h" header file which isn't shown here.

Now we just need to use that function in main. This is the complete main function at this point:
int main(int argc, char **argv) {
  conf_t conf;
  char *conf_err = parse_args(argc, argv, &conf);
  if (conf_err != NULL) {
    printf("Error: %s\n", conf_err);
    puts("Usage: render-text <font> <text> [<size> [<anti-alias>]]");
    puts("Example: render-text myfont.ttf foobar 24 yes");
    puts("         render-text myfont.ttf lorem");
    return 1;
  }

  printf("font: %s, text: %s, size: %d, anti-alias: %s\n",
         conf.font_file,
         conf.text,
         conf.size,
         conf.anti_alias ? "yes" : "no");

  FT_Face face;
  char *ft_err = render_glyph(&face, conf);
  if (ft_err != NULL) {
    printf("freetype error: %s\n", ft_err);
    return 2;
  }

  printf("bitmap rows: %d, width: %d\n",
         face->glyph->bitmap.rows,
         face->glyph->bitmap.width);

  char *png_err = render_png(face, "a.png", conf.anti_alias);
  if (png_err != NULL) {
    printf("png error: %s\n", png_err);
    return 3;
  }

  return 0;
}

To compile:
$ gcc -Wall -pedantic -std=c99 -Iinclude/ render-text.c -o render-text -Llibs/linux64 -lfreetype -lpng15 -lz -lm
If you execute the result with the proper arguments it should successfully create a file called "a.png" in the current directory. I leave it as an exercise for the reader to replace the hardcoded output file name with a command line argument.

So Long and Thanks for All the Codez

That's it for this first part. Next time (soon!) I'll show how to cross-compile this program for Windows with mingw-w64 and how to render multiple characters. If you spot any errors, have any suggestions or just want to yell at me, feel free to leave a comment below.

Here's the complete code (and again, you can find the complete project on github here: https://github.com/sendtehcodez/font-rendering/tree/part1):
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include <ft2build.h>
#include FT_FREETYPE_H

#include <png.h>

typedef struct {
  char *font_file;
  char *text;
  int size;
  int anti_alias;
} conf_t;

char *parse_args(int argc, char **argv, conf_t *conf) {
  memset(conf, 0, sizeof(conf_t));
  conf->size = 72;
  if (argc < 2) {
    return "missing font file argument";
  } else if (argc < 3) {
    return "missing text argument";
  }
  conf->font_file = argv[1];
  conf->text = argv[2];
  if (argc > 3) {
    conf->size = atoi(argv[3]);
  }
  if (argc > 4) {
    conf->anti_alias = strcmp(argv[4], "yes") == 0;
  }
  return NULL;
}

char *render_glyph(FT_Face *face, conf_t conf) {
  FT_Library ft;
  FT_Error err;

  err = FT_Init_FreeType(&ft);
  if (err) return "freetype init error";

  err = FT_New_Face(ft, conf.font_file, 0, face);
  if (err == FT_Err_Unknown_File_Format)
    return "unknown font file format";
  else if (err)
    return "error reading font file";

  err = FT_Set_Pixel_Sizes(*face, 0, conf.size);
  if (err) return "error setting font size";

  FT_UInt index = FT_Get_Char_Index(*face, *conf.text);
  if (index == 0) return "no glyph found for char";

  err = FT_Load_Glyph(*face, index, FT_LOAD_DEFAULT);
  if (err) return "error loading glyph";

  err = FT_Render_Glyph((*face)->glyph, conf.anti_alias ?
                        FT_RENDER_MODE_NORMAL :
                        FT_RENDER_MODE_MONO);
  if (err) return "error rendering glyph";

  return NULL;
}

char *render_png(FT_Face face, char *out, int aa) {
  FILE *f = fopen(out, "wb");
  if (!f) return "failed to open output file";

  png_structp png_out = png_create_write_struct(
    PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
  if (!png_out) return "failed to create png write struct";
  png_infop png_info = png_create_info_struct(png_out);
  if (!png_info)
    return "failed to create png info struct";

  if (setjmp(png_jmpbuf(png_out)))
    return "png init io error";
  png_init_io(png_out, f);

  if (setjmp(png_jmpbuf(png_out)))
    return "IHDR write error";
  png_set_IHDR(png_out,
               png_info,
               face->glyph->bitmap.width,
               face->glyph->bitmap.rows,
               aa ? 8 : 1,
               PNG_COLOR_TYPE_GRAY,
               PNG_INTERLACE_NONE,
               PNG_COMPRESSION_TYPE_DEFAULT,
               PNG_FILTER_TYPE_DEFAULT);
  png_write_info(png_out, png_info);

  if (setjmp(png_jmpbuf(png_out)))
    return "png write error";

  for (int i = 0; i < face->glyph->bitmap.rows; ++i) {
    const unsigned char *rowptr = face->glyph->bitmap.buffer +
      (face->glyph->bitmap.pitch * i);
    png_write_row(png_out, rowptr);
  }

  if (setjmp(png_jmpbuf(png_out)))
    return "png end error";
  png_write_end(png_out, NULL);

  fclose(f);
  return NULL;
}

int main(int argc, char **argv) {
  conf_t conf;
  char *conf_err = parse_args(argc, argv, &conf);
  if (conf_err != NULL) {
    printf("Error: %sn", conf_err);
    puts("Usage: render-text <font> <text> [<size> [<anti-alias>]]");
    puts("Example: render-text myfont.ttf foobar 24 yes");
    puts("         render-text myfont.ttf lorem");
    return 1;
  }

  printf("font: %s, text: %s, size: %d, anti-alias: %sn",
         conf.font_file,
         conf.text,
         conf.size,
         conf.anti_alias ? "yes" : "no");

  FT_Face face;
  char *ft_err = render_glyph(&face, conf);
  if (ft_err != NULL) {
    printf("freetype error: %sn", ft_err);
    return 2;
  }

  printf("bitmap rows: %d, width: %dn",
         face->glyph->bitmap.rows,
         face->glyph->bitmap.width);

  char *png_err = render_png(face, "a.png", conf.anti_alias);
  if (png_err != NULL) {
    printf("png error: %sn", png_err);
    return 3;
  }

  return 0;
}

Monday, January 2, 2012

Java/Swing: JTable Filter Highlighting

When you have a large table of textual data it is often useful to have a way to filter it and since Java 6 this is possible without third-party libraries using the RowFilter API. But when you search through multiple columns it is often not immediately apparent which cell contains the match.

One obvious solution to this problem is to highlight the matching text fragments. In principle this can easily be achieved by implementing a custom cell renderer that is aware of the filter string. Following the implementation of the DefaultCellRenderer we would just need a label where you can specify individual background colors for arbitrary substrings which you could then use as a basis for your custom cell renderer.

Fortunately JIDE already did the hard work for us with their StyledLabel implementation which is part of the open sourced JIDE Common Layer. With StyledLabel as a base for our custom cell renderer the implementation becomes rather trivial.

So to make it more interesting here are the requirements for our example:
  • must match in arbitrary user-specified columns
  • the filter string must be split into multiple terms at whitespace boundaries and a row must only match if each of those terms is a substring of at least one cell
  • the substring matching must be case-insensitive
  • the renderer must support alternating colors for different terms but must highlight same terms with same colors
  • the colors used for highlighting must be customizable
Seems we've got our work cut out for us so let's do it.

First of all we need a simple table:
static JTable createTable() {
    Object[][] data = new Object[][] {
        { "foo bar baz", "lorem ipsum dolor", "quux fizz buzz" },
        { "java scala clojure", "lisp haskell ml", "ruby python php" },
        { "blue green red", "orange yellow magenta", "pink cyan black" }
    };
    JTable tbl = new JTable(data, new Object[] {"1", "2", "3"});
    tbl.setAutoCreateRowSorter(true);
    return tbl;
}
Then we create a simple frame to test-drive our table:
public static void main(String[] args) throws Exception {
    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());

    final Integer[] cols = new Integer[] { 0, 1, 2 };
    final JTable tbl = createTable();
    final TableRowSorter<TableModel> sort =
        (TableRowSorter<TableModel>)tbl.getRowSorter();
    final JTextField filter = new JTextField();
    filter.addKeyListener(new KeyAdapter() {
        @Override public void keyReleased(KeyEvent e) {
            String text = filter.getText().trim();
            if (text.length() > 0) {
                final List<String> tokens = split(text.toLowerCase());
                sort.setRowFilter(makeRowFilter(tokens, cols));
            } else {
                sort.setRowFilter(null);
            }
        }
    });

    JScrollPane scroll = new JScrollPane(tbl);
    scroll.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0,
        UIManager.getColor("Panel.background").darker()));
    scroll.setVerticalScrollBarPolicy(
        JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);

    final JPanel content = new JPanel(new MigLayout("fill, ins 0"));
    content.add(new JLabel("Filter:"), "gapleft 4");
    content.add(filter, "wrap, growx, pushx, gap 4 4 4");
    content.add(scroll, "grow, pushy, span");

    final JFrame f = new JFrame("JTable Filter Highlighting");
    f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    f.setContentPane(content);
    f.setSize(400, 300);
    f.setLocationRelativeTo(null);
    f.setVisible(true);
}

For the above to compile we also need to declare the makeRowFilter method:
static RowFilter<TableModel,Integer>
makeRowFilter(final List<String> tokens, final Integer... cols) {
    return new RowFilter<TableModel,Integer>() {
        @Override public boolean
        include(Entry<? extends TableModel,? extends Integer> en) {
            for (String tok : tokens) {
                for (int i = 0; i < cols.length; ++i) {
                    String val = en.getStringValue(cols[i]).toLowerCase();
                    if (val.contains(tok)) {
                        break;
                    } else if (i == cols.length - 1) {
                        return false;
                    }
                }
            }
            return true;
        }
    };
}
So now we have a table with all the filtering features that we specified. The only thing missing is the highlighting. As I already said, this is trivial to implement on top of StyledLabel. Here's the complete renderer implementation:
static class HighlightCellRenderer
    extends StyledLabel implements TableCellRenderer {
    private List<String> terms = new ArrayList<String>();
    private Color[] colors = new Color[] {
        Color.yellow, Color.pink, Color.cyan };
    private final List<Integer> cols;
    public HighlightCellRenderer(List<Integer> cols) {
        this.cols = cols;
        setBorder(new EmptyBorder(1, 1, 1, 1));
        setOpaque(true);
    }
    public void setTerms(List<String> terms) {
        this.terms = terms;
    }
    public void clearTerms() {
        terms.clear();
    }
    public void setColors(Color... colors) {
        this.colors = colors;
    }
    public Component getTableCellRendererComponent(JTable tbl, Object val,
        boolean sel, boolean hasFocus, int row, int col) {
        String text = val != null ? val.toString() : "";
        setText(text);
        clearStyleRanges();
        final Color fg = sel ?
            tbl.getSelectionForeground() : tbl.getForeground();
        final Color bg = sel ?
            tbl.getSelectionBackground() : tbl.getBackground();
        setForeground(fg);
        setBackground(bg);
        if (cols.contains(col)) {
            String textLc = text.toLowerCase();
            for (int i = 0; i < terms.size(); ++i) {
                String term = terms.get(i);
                int offset = textLc.indexOf(term);
                while (offset != -1) {
                    addStyleRange(new StyleRange(offset, term.length(),
                        getFont().getStyle(), sel ? bg : getForeground(),
                        sel ? fg : colors[i % colors.length], 0));
                    offset = textLc.indexOf(term, offset + 1);
                }
            }
        }

        configureBorder(tbl, sel, hasFocus, row, col);

        return this;
    }

    private void configureBorder(
        JTable tbl, boolean sel, boolean hasFocus,int row, int col) {
        // copied from DefaultTableCellRenderer
        if (hasFocus) {
            Border border = null;
            if (sel) {
                border = DefaultLookup.getBorder(
                    this, ui, "Table.focusSelectedCellHighlightBorder");
            }
            if (border == null) {
                border = DefaultLookup.getBorder(
                    this, ui, "Table.focusCellHighlightBorder");
            }
            setBorder(border);
            if (!sel && tbl.isCellEditable(row, col)) {
                Color color;
                color = DefaultLookup.getColor(
                    this, ui, "Table.focusCellForeground");
                if (color != null) {
                    super.setForeground(color);
                }
                color = DefaultLookup.getColor(
                    this, ui, "Table.focusCellBackground");
                if (color != null) {
                    super.setBackground(color);
                }
            }
        } else {
            setBorder(getNoFocusBorder());
        }
    }

    // copied from DefaultTableCellRenderer
    static final Border SAFE_NO_FOCUS_BORDER = new EmptyBorder(1, 1, 1, 1);
    static final Border DEFAULT_NO_FOCUS_BORDER = new EmptyBorder(1, 1, 1, 1);
    protected static Border noFocusBorder = DEFAULT_NO_FOCUS_BORDER;
    private Border getNoFocusBorder() {
        Border border = DefaultLookup.getBorder(
            this, ui, "Table.cellNoFocusBorder");
        if (System.getSecurityManager() != null) {
            if (border != null) return border;
            return SAFE_NO_FOCUS_BORDER;
        } else if (border != null) {
            if (noFocusBorder == null
                || noFocusBorder == DEFAULT_NO_FOCUS_BORDER) {
                return border;
            }
        }
        return noFocusBorder;
    }

    // overridden for performance reasons
    @Override public void invalidate() {}
    @Override public void validate() {}
    @Override public void revalidate() {}
    @Override public void repaint(long tm, int x, int y, int w, int h) {}
    @Override public void repaint(Rectangle r) { }
    @Override public void repaint() {}
}
To use it we just need to change a few lines in our test code:
final HighlightCellRenderer renderer = new HighlightCellRenderer(
    Arrays.asList(cols));
tbl.setDefaultRenderer(Object.class, renderer);
final JTextField filter = new JTextField();
filter.addKeyListener(new KeyAdapter() {
    @Override public void keyReleased(KeyEvent e) {
        String text = filter.getText().trim();
        if (text.length() > 0) {
            final List<String> tokens = split(text.toLowerCase());
            sort.setRowFilter(makeRowFilter(tokens, cols));
            renderer.setTerms(tokens);
        } else {
            sort.setRowFilter(null);
            renderer.clearTerms();
        }
    }
});

And that's it:
































Here's the complete code for this example (coded against com.jidesoft:jide-oss:2.10.2 and com.miglayout:miglayout-swing:4.1):
package sendtehcodez;

import java.awt.Color;
import java.awt.Component;
import java.awt.Rectangle;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.StringTokenizer;

import javax.swing.BorderFactory;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.RowFilter;
import javax.swing.UIManager;
import javax.swing.border.Border;
import javax.swing.border.EmptyBorder;
import javax.swing.table.TableCellRenderer;
import javax.swing.table.TableModel;
import javax.swing.table.TableRowSorter;

import net.miginfocom.swing.MigLayout;
import sun.swing.DefaultLookup;

import com.jidesoft.swing.StyleRange;
import com.jidesoft.swing.StyledLabel;

public class JTableFilterHighlighting {
    public static void main(String[] args) throws Exception {
        UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());

        final Integer[] cols = new Integer[] { 0, 1, 2 };
        final JTable tbl = createTable();
        final TableRowSorter<TableModel> sort =
            (TableRowSorter<TableModel>)tbl.getRowSorter();
        final HighlightCellRenderer renderer = new HighlightCellRenderer(
            Arrays.asList(cols));
        tbl.setDefaultRenderer(Object.class, renderer);
        final JTextField filter = new JTextField();
        filter.addKeyListener(new KeyAdapter() {
            @Override public void keyReleased(KeyEvent e) {
                String text = filter.getText().trim();
                if (text.length() > 0) {
                    final List<String> tokens = split(text.toLowerCase());
                    sort.setRowFilter(makeRowFilter(tokens, cols));
                    renderer.setTerms(tokens);
                } else {
                    sort.setRowFilter(null);
                    renderer.clearTerms();
                }
            }
        });

        JScrollPane scroll = new JScrollPane(tbl);
        scroll.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0,
            UIManager.getColor("Panel.background").darker()));
        scroll.setVerticalScrollBarPolicy(
            JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);

        final JPanel content = new JPanel(new MigLayout("fill, ins 0"));
        content.add(new JLabel("Filter:"), "gapleft 4");
        content.add(filter, "wrap, growx, pushx, gap 4 4 4");
        content.add(scroll, "grow, pushy, span");

        final JFrame f = new JFrame("JTable Filter Highlighting");
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        f.setContentPane(content);
        f.setSize(400, 300);
        f.setLocationRelativeTo(null);
        f.setVisible(true);
    }

    static RowFilter<TableModel,Integer>
    makeRowFilter(final List<String> tokens, final Integer... cols) {
        return new RowFilter<TableModel,Integer>() {
            @Override public boolean
            include(Entry<? extends TableModel,? extends Integer> en) {
                for (String tok : tokens) {
                    for (int i = 0; i < cols.length; ++i) {
                        String val = en.getStringValue(cols[i]).toLowerCase();
                        if (val.contains(tok)) {
                            break;
                        } else if (i == cols.length - 1) {
                            return false;
                        }
                    }
                }
                return true;
            }
        };
    }

    static List<String> split(String str) {
        List<String> tokens = new ArrayList<String>();
        StringTokenizer tok = new StringTokenizer(str);
        while (tok.hasMoreTokens()) {
            String token = tok.nextToken().trim();
            if (token.length() > 0) {
                tokens.add(token);
            }
        }
        return tokens;
    }

    static JTable createTable() {
        Object[][] data = new Object[][] {
            { "foo bar baz", "lorem ipsum dolor", "quux fizz buzz" },
            { "java scala clojure", "lisp haskell ml", "ruby python php" },
            { "blue green red", "orange yellow magenta", "pink cyan black" }
        };
        JTable tbl = new JTable(data, new Object[] {"1", "2", "3"});
        tbl.setAutoCreateRowSorter(true);
        return tbl;
    }

    static class HighlightCellRenderer
        extends StyledLabel implements TableCellRenderer {
        private List<String> terms = new ArrayList<String>();
        private Color[] colors = new Color[] {
            Color.yellow, Color.pink, Color.cyan };
        private final List<Integer> cols;
        public HighlightCellRenderer(List<Integer> cols) {
            this.cols = cols;
            setBorder(new EmptyBorder(1, 1, 1, 1));
            setOpaque(true);
        }
        public void setTerms(List<String> terms) {
            this.terms = terms;
        }
        public void clearTerms() {
            terms.clear();
        }
        public void setColors(Color... colors) {
            this.colors = colors;
        }
        public Component getTableCellRendererComponent(JTable tbl, Object val,
            boolean sel, boolean hasFocus, int row, int col) {
            String text = val != null ? val.toString() : "";
            setText(text);
            clearStyleRanges();
            final Color fg = sel ?
                tbl.getSelectionForeground() : tbl.getForeground();
            final Color bg = sel ?
                tbl.getSelectionBackground() : tbl.getBackground();
            setForeground(fg);
            setBackground(bg);
            if (cols.contains(col)) {
                String textLc = text.toLowerCase();
                for (int i = 0; i < terms.size(); ++i) {
                    String term = terms.get(i);
                    int offset = textLc.indexOf(term);
                    while (offset != -1) {
                        addStyleRange(new StyleRange(offset, term.length(),
                            getFont().getStyle(), sel ? bg : getForeground(),
                            sel ? fg : colors[i % colors.length], 0));
                        offset = textLc.indexOf(term, offset + 1);
                    }
                }
            }

            configureBorder(tbl, sel, hasFocus, row, col);

            return this;
        }

        private void configureBorder(
            JTable tbl, boolean sel, boolean hasFocus,int row, int col) {
            // copied from DefaultTableCellRenderer
            if (hasFocus) {
                Border border = null;
                if (sel) {
                    border = DefaultLookup.getBorder(
                        this, ui, "Table.focusSelectedCellHighlightBorder");
                }
                if (border == null) {
                    border = DefaultLookup.getBorder(
                        this, ui, "Table.focusCellHighlightBorder");
                }
                setBorder(border);
                if (!sel && tbl.isCellEditable(row, col)) {
                    Color color;
                    color = DefaultLookup.getColor(
                        this, ui, "Table.focusCellForeground");
                    if (color != null) {
                        super.setForeground(color);
                    }
                    color = DefaultLookup.getColor(
                        this, ui, "Table.focusCellBackground");
                    if (color != null) {
                        super.setBackground(color);
                    }
                }
            } else {
                setBorder(getNoFocusBorder());
            }
        }

        // copied from DefaultTableCellRenderer
        static final Border SAFE_NO_FOCUS_BORDER = new EmptyBorder(1, 1, 1, 1);
        static final Border DEFAULT_NO_FOCUS_BORDER = new EmptyBorder(1, 1, 1, 1);
        protected static Border noFocusBorder = DEFAULT_NO_FOCUS_BORDER;
        private Border getNoFocusBorder() {
            Border border = DefaultLookup.getBorder(
                this, ui, "Table.cellNoFocusBorder");
            if (System.getSecurityManager() != null) {
                if (border != null) return border;
                return SAFE_NO_FOCUS_BORDER;
            } else if (border != null) {
                if (noFocusBorder == null
                    || noFocusBorder == DEFAULT_NO_FOCUS_BORDER) {
                    return border;
                }
            }
            return noFocusBorder;
        }

        // overridden for performance reasons
        @Override public void invalidate() {}
        @Override public void validate() {}
        @Override public void revalidate() {}
        @Override public void repaint(long tm, int x, int y, int w, int h) {}
        @Override public void repaint(Rectangle r) { }
        @Override public void repaint() {}
    }
}

Saturday, December 24, 2011

Java/SwingX: Better UX for Tables with Hyperlinks

In my first post I want to show you how you can improve the user experience of JXHyperlink's in JXTable's. Both of those classes are part of SwingX which, although apparently dead, is still a very useful collection of Swing components that should really be in Swing itself. Out of the box you can use the mentioned classes to display hyperlinks in tables with the HyperlinkProvider and the DefaultTableRenderer implementation with just a few lines of code:
JXTable table = new JXTable(model);
table.setEditable(false);
AbstractHyperlinkAction<URI> act = new AbstractHyperlinkAction<URI>() {
    public void actionPerformed(ActionEvent ev) {
        try {
            Desktop.getDesktop().browse(target);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
};
table.setDefaultRenderer(URI.class,
    new DefaultTableRenderer(new HyperlinkProvider(act)));

This is nice but if you hover with your mouse over a cell which contains a hyperlink it will be rendered in rollover state even if the mouse is not really above the link text. That's because the rollover support in SwingX works on a cell basis. This also has the unfortunate effect of triggering the associated link action as soon as you click inside the cell even if you didn't click on the link itself. This is counterintuitive because that's not how links usually work. Fortunately we can modify this behavior by overriding the relevant parts in HyperlinkProvider and JXTable.

First of all we need a way to determine if the mouse is hovering above the actual text of a cell or not. For this purpose we implement the utility function isRolloverText:
static private boolean isRolloverText(JXTable tbl, int row, int col) {
    int mCol = tbl.convertColumnIndexToModel(col);
    int mRow = tbl.convertRowIndexToModel(row);
    if (tbl.getColumnClass(col) != URI.class) {
        return false;
    }
    Point pos = tbl.getMousePosition();
    if (pos == null) {
        return false;
    }
    AbstractRenderer ren = (AbstractRenderer)tbl.getCellRenderer(row, col);
    Object value = tbl.getModel().getValueAt(mRow, mCol);
    ComponentProvider<? extends JComponent> prov = ren
        .getComponentProvider();
    String text = prov.getString(value);
    if (text.length() == 0) {
        return false;
    }
    JComponent com = prov.getRendererComponent(null);
    int textWidth = com.getFontMetrics(com.getFont())
        .stringWidth(text);
    Rectangle cellBounds = tbl.getCellRect(row, col, true);
    Rectangle textBounds = new Rectangle(
        cellBounds.x, cellBounds.y, textWidth, cellBounds.height);
    return textBounds.contains(pos);
}
Note that this implementation assumes that the whole cell is used for rendering the text. For example, if you customize the hyperlink cell renderer to render an icon on the left side of the text this code wouldn't work properly anymore.

Now that we have a way to tell if the mouse is above the cell text we can override the HyperlinkProvider to only set the rollover state if the cursor is really above the text. In this example I just copied the implementation from the superclass and added the highlighted line:
@Override protected void configureState(CellContext context) {
    if (context.getComponent() == null) {
        return;
    }
    Point p = (Point) context.getComponent()
        .getClientProperty(RolloverProducer.ROLLOVER_KEY);
    if (p != null && (p.x >= 0)
        && (p.x == context.getColumn()) && (p.y == context.getRow())
        && isRolloverText((JXTable)context.getComponent(), p.y, p.x)
        && !rendererComponent.getModel().isRollover()) {
        rendererComponent.getModel().setRollover(true);
    }
    else if (rendererComponent.getModel().isRollover()) {
        rendererComponent.getModel().setRollover(false);
    }
}
Then we also need to modify the JXTable's rollover support:
@Override protected RolloverProducer createRolloverProducer() {
    return new TableRolloverProducer() {
        @Override public void mouseMoved(MouseEvent ev) {
            updateRollover(ev, ROLLOVER_KEY, true);
        }
    };
}
@Override protected TableRolloverController<JXTable>
createLinkController() {
    return new TableRolloverController<JXTable>() {
        @Override protected RolloverRenderer getRolloverRenderer(
            Point loc, boolean prep) {
            if (getColumnClass(loc.x) == URI.class
                && !isRolloverText(component, loc.y, loc.x)) {
                return null;
            }
            return super.getRolloverRenderer(loc, prep);
        }
    };
}
And that's it:





Here's the complete example (coded against org.swinglabs:swingx-core:1.6.2-2):
package sendtehcodez;

import java.awt.Desktop;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.MouseEvent;
import java.net.URI;
import java.net.URISyntaxException;

import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JScrollPane;
import javax.swing.UIManager;
import javax.swing.table.DefaultTableModel;

import org.jdesktop.swingx.JXTable;
import org.jdesktop.swingx.hyperlink.AbstractHyperlinkAction;
import org.jdesktop.swingx.renderer.AbstractRenderer;
import org.jdesktop.swingx.renderer.CellContext;
import org.jdesktop.swingx.renderer.ComponentProvider;
import org.jdesktop.swingx.renderer.DefaultTableRenderer;
import org.jdesktop.swingx.renderer.HyperlinkProvider;
import org.jdesktop.swingx.rollover.RolloverProducer;
import org.jdesktop.swingx.rollover.RolloverRenderer;
import org.jdesktop.swingx.rollover.TableRolloverController;
import org.jdesktop.swingx.rollover.TableRolloverProducer;

public class TablesWithHyperlinks {
    public static void main(String[] args) throws Exception {
        UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());

        JScrollPane scroll = new JScrollPane(createTable());
        scroll.setBorder(BorderFactory.createEmptyBorder());

        final JFrame f = new JFrame("Tables with Hyperlinks");
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        f.getContentPane().add(scroll);
        f.setSize(400, 300);
        f.setLocationRelativeTo(null);
        f.setVisible(true);
    }

    static private boolean isRolloverText(JXTable tbl, int row, int col) {
        int mCol = tbl.convertColumnIndexToModel(col);
        int mRow = tbl.convertRowIndexToModel(row);
        if (tbl.getColumnClass(col) != URI.class) {
            return false;
        }
        Point pos = tbl.getMousePosition();
        if (pos == null) {
            return false;
        }
        AbstractRenderer ren = (AbstractRenderer)tbl.getCellRenderer(row, col);
        Object value = tbl.getModel().getValueAt(mRow, mCol);
        ComponentProvider<? extends JComponent> prov = ren
            .getComponentProvider();
        String text = prov.getString(value);
        if (text.length() == 0) {
            return false;
        }
        JComponent com = prov.getRendererComponent(null);
        int textWidth = com.getFontMetrics(com.getFont())
            .stringWidth(text);
        Rectangle cellBounds = tbl.getCellRect(row, col, true);
        Rectangle textBounds = new Rectangle(
            cellBounds.x, cellBounds.y, textWidth, cellBounds.height);
        return textBounds.contains(pos);
    }

    static JXTable createTable() throws URISyntaxException {
        Object[][] data = new Object[][] {
            { new URI("http://java.oracle.com"), "Java" },
            { new URI("http://clojure.org"), "Clojure" },
            { new URI("http://haskell.org"), "Haskell" },
            { new URI("http://erlang.org"), "Erlang" },
            { new URI("http://python.org"), "Python" },
            { new URI("http://ruby-lang.org"), "Ruby" }
        };
        DefaultTableModel model = new DefaultTableModel(data,
            new Object[] { "URI", "Language" }) {
            @Override public Class<?> getColumnClass(int col) {
                if (col == 0) {
                    return URI.class;
                }
                return super.getColumnClass(col);
            }
        };
        JXTable table = new JXTable(model) {
            @Override protected RolloverProducer createRolloverProducer() {
                return new TableRolloverProducer() {
                    @Override public void mouseMoved(MouseEvent ev) {
                        updateRollover(ev, ROLLOVER_KEY, true);
                    }
                };
            }
            @Override protected TableRolloverController<JXTable>
            createLinkController() {
                return new TableRolloverController<JXTable>() {
                    @Override protected RolloverRenderer getRolloverRenderer(
                        Point loc, boolean prep) {
                        if (getColumnClass(loc.x) == URI.class
                            && !isRolloverText(component, loc.y, loc.x)) {
                            return null;
                        }
                        return super.getRolloverRenderer(loc, prep);
                    }
                };
            }
        };

        AbstractHyperlinkAction<URI> act = new AbstractHyperlinkAction<URI>() {
            public void actionPerformed(ActionEvent ev) {
                try {
                    Desktop.getDesktop().browse(target);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        };
        HyperlinkProvider hp = new HyperlinkProvider(act) {
            @Override protected void configureState(CellContext context) {
                if (context.getComponent() == null) {
                    return;
                }
                Point p = (Point) context.getComponent()
                    .getClientProperty(RolloverProducer.ROLLOVER_KEY);
                if (p != null && (p.x >= 0)
                    && (p.x == context.getColumn()) && (p.y == context.getRow())
                    && isRolloverText((JXTable)context.getComponent(), p.y, p.x)
                    && !rendererComponent.getModel().isRollover()) {
                    rendererComponent.getModel().setRollover(true);
                }
                else if (rendererComponent.getModel().isRollover()) {
                    rendererComponent.getModel().setRollover(false);
                }
            }
        };
        table.setDefaultRenderer(URI.class, new DefaultTableRenderer(hp));
        table.setEditable(false);
        table.setColumnControlVisible(true);

        return table;
    }
}

About

I'm some random guy screwing around mostly with Java nowadays and sometimes C or C++. I used to work a lot with PHP but I don't really use it anymore. I'm madly in love with functional programming and Lisp in general and Clojure1 in particular. Also I think Haskell is the bomb and the most awesomest thing since sliced bread :-) 

I want to use this blog to collect cookbook-style code snippets that I needed at some point which I think could be useful to other people.
At the time of this writing I'm currently developing a Java/Swing application so my first posts will be about stuff I needed to do in that application that I think could be useful for someone else. We'll see how it goes from there.

1 Rich Hickey is my hero :-)