Tabular Debugging View in Bloc

Imagine the following situation:

Parsing CSV string

I am trying to parse a simple csv line with a parser that seems to be failing. The inspector kindly tells me every match that was found within the string. But can I understand what is going on just by looking at the text? The matches are clearly overlapping, several have no names, and more:

  • What exactly does the match beg=7, end=11, scope='' refer to?
  • Are the scopes with no names related?
  • Is there any match missing?
  • Are any matches applied to an incorrect interval?

It would be nice to have another perspective on the match data that would help us answer these questions. First idea was to make a table that will highlight the individual match rules.

I opened a spreadsheet editor and by hand constructed the following: Spreadsheet view on the match intervals

Now I can clearly see which match is which, that the matches with empty scopes are indeed related. It is also obvious that several matches are missing: all but the first comma, and the first field.

This is nice and all, but I cannot imagine doing this by hand for any random content and syntax. So… why not build it in Pharo? It is also a good excuse to play more with Bloc. :)

Bloc is full of examples that nicely demonstrate particular scenarios, but sometimes it can be a bit overwhelming when you are not sure what you are looking for. Thankfully Aliaksei Syrel pointed to me to BlGridLayoutExamples class>>#exampleNautilusGrid that demonstrates both grid-like layout, and the capability for a cell to span multiple columns, similarly to HTML’s colspan. BlGridLayoutExamples class>>#exampleNautilusGrid

Detour – Building a Matrix View

Later in the example I will be using Matrix; unfortunately Matrix does not have a nice view on its content, so how on Earth would I know if everything is where it should be? Considering what I am trying to build is just more complex version of Matrix, I might as well build Matrix view first. Nothing complex here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
"Create matrix with some content"
alphabet := $A to: $Z.
data := Matrix rows: 10 columns: 6 tabulate: [ :y :x |
(alphabet at: x) asString, y asString.
].
"Create a new Matrix holding the BlElements for each cell"
cells := Matrix rows: data rowCount columns: data columnCount tabulate: [ :y :x |
|e|
e := BlElement new.
e background: Color white.
"Explicit #asString because we want to show `nil`"
e addChild: (BlTextElement new
text: (data at: y at: x) asString asRopedText).
e ].
container := BlElement new
layout: (BlGridLayout new
columnCount: cells columnCount;
cellSpacing: 1);
constraintsDo: [ :c |
c horizontal fitContent.
c vertical fitContent.
];
background: Color veryLightGray.
"Combination of different backgrounds and #cellSpacing = 1 gives the cell a border"
cells do: [ :each | container addChild: each ].
container
Matrix view v1

Cool! We just need to make some additional amendments before we convert it into a GTInspector extension for the Matrix class.

Text Centering

Text is centered using constraints, but not all layouts support it. Browsing #alignCenter shows the supported ones. The layout must be set to the parent of BlTextElement.

#alignCenter layout implementors
1
2
3
4
5
6
7
8
9
10
11
12
e := BlElement new.
e layout: BlFrameLayout new.
e border: (BlBorder paint: Color black width: 1).
e size: 50 @ 50.
e background: Color white.
e addChild: (BlTextElement new
constraintsDo: [ :c |
c frame vertical alignCenter.
c frame horizontal alignCenter.
];
text: 'A5' asRopedText).
e
alignCenter.png

Automatic Width Adjustment

At the moment the size of each cell is fixed to 50@50, so if there is any content exceeding this, it will get clipped.

Normally we could use fitContent constraint to adjust the cell’s width based on the text width, but we also want to change the width of all cells in the same column. So in a sense we want to apply both fitContent (for the widest element), and matchParent (for the rest) constraints, which these of course are competing constraints.

The clean (I guess) approach is to use custom BlElement objects and override onMeasure: (iirc) which will look at the parent/children/whatever. For the sake of simplicity I am going with a different approach:

  • force the layout on all cell elements (otherwise the width will not be calculated until the presentation is about to be drawn)
  • in each column look for the widest text
  • change the width of the cells in the column
1
2
3
4
5
6
7
cells do: #forceLayout.
1 to: cells columnCount do: [ :x |
| column width |
column := cells atColumn: x.
"the text is the first child of the cell"
width := 50 max: (column collect: [ :each | each children first width ]) max.
column do: [ :each | each width: width ] ].

minWidth: constraint is not supported yet (at the time of writing), so I need to do it myself (50 max: ...).

Matrix Presentation

To determine how to create a Bloc inspector extension, we can look at the one we have been looking all this time: BlElement>>gtInspectorLiveIn:. Put all together…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Matrix>>gtInspectorMatrixViewIn: aComposite
<gtInspectorPresentationOrder: 10>
^ aComposite bloc
title: 'Matrix' translated;
element: [ | cells container |
cells := Matrix
rows: self rowCount
columns: self columnCount
tabulate: [ :y :x |
| e |
e := BlElement new.
e size: 25 @ 25.
e layout: BlFrameLayout new.
e background: Color white.
e
addChild:
(BlTextElement new
constraintsDo: [ :c |
c frame vertical alignCenter.
c frame horizontal alignCenter ];
text: (self at: y at: x) asString asRopedText).
e ].
cells do: #forceLayout.
1 to: cells columnCount do: [ :x |
| column width |
column := cells atColumn: x.
width := 25 max: (column collect: [ :each | each children first width ]) max.
column do: [ :each | each width: width ] ].
container := BlElement new
layout:
(BlGridLayout new
columnCount: cells columnCount;
cellSpacing: 1);
constraintsDo: [ :c |
c horizontal fitContent.
c vertical fitContent ];
background: Color veryLightGray.
cells do: [ :each | container addChild: each ].
container ]

The final result:

1
2
3
4
5
6
matrix := Matrix rows: 8 columns: 5 tabulate: [ :y :x |
y asString, ';', x asString
].
matrix at: 5 at: 3 put: 'loger text'.
matrix at: 8 at: 5 put: 'much longer text'.
matrix.
Matrix Presentation in action

Building the View

Now we can (at last!) get back to the task at hand.

Spreadsheet view

To build the view, we need a way to represent both the actual content, as well as the grid. At the same time I would like to keep it as a self-contained contained script, so no custom objects are allowed.

Table Content

For the content, I will use mock data to keep this example self-contained.

Source data:

1
2
3
4
5
6
7
8
9
10
11
12
text := 'aa,bb,"cc",dd'.
ranges := #(
#(1 13 'text.tabular.csv')
#(3 3 'punctuation.separator.tabular.field.csv')
#(4 6 '')
#(4 5 'meta.tabular.field.csv')
#(7 11 '')
#(8 9 'meta.tabular.field.quoted.csv')
#(12 13 '')
#(12 13 'meta.tabular.field.csv')
).

Building the content Matrix:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
"index header + index footer + text line + ranges size"
rowsCount := 1 + 1 + 1 + ranges size.
"text size + side info"
columnsCount := text size + 3.
"Matrix containing the content of the View"
content := Matrix rows: rowsCount columns: columnsCount element: ''.
"index header and footer"
1 to: (columnsCount min: text size) do: [ :x |
content at: 1 at: x put: x asString.
content at: rowsCount at: x put: x asString.
].
"text line"
text withIndexDo: [ :c :i |
content at: 2 at: i put: c asString.
].
"matches"
ranges withIndexDo: [ :each :y |
content at: y+2 at: each first put: {each. '^', y asString. each second - each first + 1}.
each first + 1 to: each second do: [ :x |
"cells that should be merged will be nil"
content at: y+2 at: x put: nil.
].
content at: y+2 at: text size + 2 put: '^', y asString.
content at: y+2 at: text size + 3 put: each last.
].

I can use my Matrix extension to see if content is what it should be. As a sidenote, I have created two views. One with fixed width, and one with auto-adjusting so I can see both the detail, and the whole picture.

View of `content`

Exactly what it should be; now to make a spreadsheet view for it.

Colors

In the spreadsheet I have assigned colors to the scopes. That should be generated automatically as the number and types of scopes will vary.

For that we use Color class>>wheel:, which generates a number of unique colors.

1
2
3
4
scopes := (ranges collect: #last) asSet asArray.
colorWheel := Color wheel: scopes size.
colors := (scopes withIndexCollect: [ :each :i |
each -> (colorWheel at: i) ]) asDictionary.

And while we are at it, why not visualize the colors too. :)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scopeElements := scopes collect: [ :each |
BlElement new
layout: BlFrameLayout new;
constraintsDo: [ :c |
c minWidth: 200.
c horizontal fitContent.
];
background: (colors at: each);
addChild: (BlTextElement new text: each asRopedText).
].
container := BlElement new
layout: BlLinearLayout vertical;
constraintsDo: [ :c |
c horizontal fitContent.
c vertical fitContent ];
addChildren: scopeElements.
colors.png

Tabular View

The view is a variation of the matrix view with merged cells and different sizing.

Merging cells is achieved by increasing the span of the cell that is taking over the rest; e.g. c grid horizontal span: 3.

I chose to have exact width of cells, which means that I need to compute the width of the merged cells. The calculated width must account for the space between cells too (cellSpacing specified on container).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
cellSize := 20.
cells := Matrix rows: content rowCount columns: content columnCount tabulate: [ :y :x |
"don't create Elements for cell positions that will be merged"
(content at: y at: x)
ifNil: [ nil ]
ifNotNil: [ :cellData | |cellText span color e|
cellText := cellData.
span := 1.
color := Color white.
cellData isArray ifTrue: [
cellText := cellData second.
span := cellData last.
color := colors at: cellData first last.
].
e := BlElement new
layout: BlFrameLayout new;
height: cellSize;
background: color;
constraintsDo: [ :c |
"occupy `span` many cells *horizontally* (colspan)"
c grid horizontal span: span.
].
"1 extra pixel for the border between each pair of merged cells"
e width: (cellSize + 1) * span - 1.
e addChild: (BlTextElement new
constraintsDo: [ :c |
c frame vertical alignCenter.
c frame horizontal alignCenter.
];
text: cellText asRopedText).
e ]
].
"adjust the width & alignment of the last column"
(cells atColumn: columnsCount) do: [ :each |
"this is good enough as the scopes aren't that long
but looking for widest string would work too"
each width: 15 * cellSize.
each children first constraintsDo: [ :c |
c frame horizontal alignLeft.
].
].
container := BlElement new
layout:
(BlGridLayout new
columnCount: cells columnCount;
cellSpacing: 1);
constraintsDo: [ :c |
c horizontal fitContent.
c vertical fitContent ];
background: Color veryLightGray.
cells do: [ :each | each ifNotNil: [ container addChild: each ] ].
container

And we are done. :)

Final view