Skip to content

Commit 4320f81

Browse files
committed
Merge pull request #54 from basho/jrd/proportional-shrink
Proportional table shrinking algorithm
2 parents edd30cd + 4584aa3 commit 4320f81

File tree

1 file changed

+128
-52
lines changed

1 file changed

+128
-52
lines changed

src/clique_table.erl

Lines changed: 128 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,19 @@
2222

2323
%% API
2424
-export([print/2, print/3,
25-
create_table/2, autosize_create_table/2]).
25+
create_table/2,
26+
autosize_create_table/2, autosize_create_table/3]).
2627

2728
-include("clique_status_types.hrl").
2829

2930
-define(MAX_LINE_LEN, 100).
31+
-define(else, true).
32+
-define(MINWIDTH(W),
33+
if W =< 0 ->
34+
1;
35+
?else ->
36+
W
37+
end).
3038

3139
-spec print(list(), list()) -> ok.
3240
print(_Spec, []) ->
@@ -48,13 +56,21 @@ print(Header, Spec, Rows) ->
4856

4957
-spec autosize_create_table([any()], [[any()]]) -> iolist().
5058
autosize_create_table(Schema, Rows) ->
59+
autosize_create_table(Schema, Rows, []).
60+
61+
%% Currently the only constraint supported in the proplist is
62+
%% `fixed_width' with a list of columns that *must not* be shrunk
63+
%% (e.g., integer values). First column is 0.
64+
-spec autosize_create_table([any()], [[any()]], [tuple()]) -> iolist().
65+
autosize_create_table(Schema, Rows, Constraints) ->
5166
BorderSize = 1 + length(hd(Rows)),
5267
MaxLineLen = case io:columns() of
53-
%% Leaving an extra space seems to work better
54-
{ok, N} -> N - 1;
55-
{error, enotsup} -> ?MAX_LINE_LEN
56-
end,
57-
Sizes = get_field_widths(MaxLineLen - BorderSize, [Schema | Rows]),
68+
%% Leaving an extra space seems to work better
69+
{ok, N} -> N - 1;
70+
{error, enotsup} -> ?MAX_LINE_LEN
71+
end,
72+
Sizes = get_field_widths(MaxLineLen - BorderSize, [Schema | Rows],
73+
proplists:get_value(fixed_width, Constraints, [])),
5874
Spec = lists:zip(Schema, Sizes),
5975
create_table(Spec, Rows, MaxLineLen, []).
6076

@@ -81,54 +97,114 @@ create_table(Spec, [], _Length, IoList) ->
8197
create_table(Spec, [Row | Rows], Length, IoList) ->
8298
create_table(Spec, Rows, Length, [row(Spec, Row) | IoList]).
8399

84-
-spec get_field_widths(pos_integer(), [term()]) -> [pos_integer()].
85-
get_field_widths(MaxLineLen, Rows) ->
100+
%% Measure and shrink table width as necessary to fit the console
101+
-spec get_field_widths(pos_integer(), [term()], [non_neg_integer()]) -> [non_neg_integer()].
102+
get_field_widths(MaxLineLen, Rows, Unshrinkable) ->
86103
Widths = max_widths(Rows),
87-
resize_row(MaxLineLen, Widths).
104+
fit_widths_to_terminal(MaxLineLen, Widths, Unshrinkable).
88105

89-
-spec resize_row(pos_integer(), [pos_integer()]) -> [pos_integer()].
90-
resize_row(MaxLength, Widths) ->
106+
fit_widths_to_terminal(MaxWidth, Widths, Unshrinkable) ->
91107
Sum = lists:sum(Widths),
92-
case Sum > MaxLength of
93-
true ->
94-
resize_items(Sum, MaxLength, Widths);
95-
false ->
96-
Widths
97-
end.
98-
99-
-spec resize_items(pos_integer(), pos_integer(), [pos_integer()]) ->
100-
[pos_integer()].
101-
resize_items(Sum, MaxLength, Widths) ->
102-
Diff = Sum - MaxLength,
103-
NumColumns = length(Widths),
104-
case NumColumns > Diff of
105-
true ->
106-
Remaining = NumColumns - Diff ,
107-
reduce_widths(1, Remaining, Widths);
108-
false ->
109-
PerColumn = Diff div NumColumns + 1,
110-
Remaining = Diff - NumColumns,
111-
reduce_widths(PerColumn, Remaining, Widths)
112-
end.
113-
-spec reduce_widths(pos_integer(), pos_integer(), [pos_integer()]) ->
114-
[pos_integer()].
115-
reduce_widths(PerColumn, Total, Widths) ->
116-
%% Just subtract one character from each column until we run out.
108+
Weights = calculate_field_weights(Sum, Widths, Unshrinkable),
109+
MustRemove = Sum - MaxWidth,
110+
calculate_new_widths(MaxWidth, MustRemove, Widths, Weights).
111+
112+
%% Determine field weighting as proportion of total width of the
113+
%% table. Fields which were flagged as unshrinkable will be given a
114+
%% weight of 0.
115+
-spec calculate_field_weights(pos_integer(), list(pos_integer()),
116+
list(non_neg_integer())) ->
117+
list(number()).
118+
calculate_field_weights(Sum, Widths, []) ->
119+
%% If no fields are constrained as unshrinkable, simply divide
120+
%% each width by the sum of all widths for our proportions
121+
lists:map(fun(X) -> X / Sum end, Widths);
122+
calculate_field_weights(_Sum, Widths, Unshrinkable) ->
123+
TaggedWidths = flag_unshrinkable_widths(Widths, Unshrinkable),
124+
ShrinkableWidth = lists:sum(lists:filter(fun({_X, noshrink}) -> false;
125+
(_X) -> true end,
126+
TaggedWidths)),
127+
lists:map(fun({_X, noshrink}) -> 0;
128+
(X) -> X / ShrinkableWidth end,
129+
TaggedWidths).
130+
131+
%% Takes a list of column widths and a list of (zero-based) index
132+
%% values of the columns that must not shrink. Returns a mixed list of
133+
%% widths and `noshrink' tuples.
134+
flag_unshrinkable_widths(Widths, NoShrink) ->
117135
{_, NewWidths} =
118-
lists:foldl(fun(Width, {Remaining, NewWidths}) ->
119-
case Remaining of
120-
0 ->
121-
{0, [Width | NewWidths]};
122-
_ ->
123-
Rem = Remaining - PerColumn,
124-
{Rem, [Width - PerColumn | NewWidths]}
125-
end
126-
end, {Total, []}, Widths),
136+
lists:foldl(fun(X, {Idx, Mapped}) ->
137+
case lists:member(Idx, NoShrink) of
138+
true ->
139+
{Idx + 1, [{X, noshrink}|Mapped]};
140+
false ->
141+
{Idx + 1, [X|Mapped]}
142+
end
143+
end, {0, []}, Widths),
127144
lists:reverse(NewWidths).
128145

146+
%% Calculate the proportional weight for each column for shrinking.
147+
%% Zip the results into a `{Width, Weight, Index}' tuple list.
148+
column_zip(Widths, Weights, ToNarrow) ->
149+
column_zip(Widths, Weights, ToNarrow, 0, []).
150+
151+
column_zip([], [], _ToNarrow, _Index, Accum) ->
152+
lists:reverse(Accum);
153+
column_zip([Width|Widths], [Weight|Weights], ToNarrow, Index, Accum) ->
154+
NewWidth = ?MINWIDTH(Width - round(ToNarrow * Weight)),
155+
column_zip(Widths, Weights, ToNarrow, Index+1,
156+
[{NewWidth, Weight, Index}] ++ Accum).
157+
158+
%% Given the widths based on data to be displayed, return widths
159+
%% necessary to narrow the table to fit the console.
160+
calculate_new_widths(_Max, ToNarrow, Widths, _Weights) when ToNarrow =< 0 ->
161+
%% Console is wide enough, no need to narrow
162+
Widths;
163+
calculate_new_widths(MaxWidth, ToNarrow, Widths, Weights) ->
164+
fix_rounding(MaxWidth, column_zip(Widths, Weights, ToNarrow)).
165+
166+
%% Rounding may introduce an error. If so, remove the requisite number
167+
%% of spaces from the widest field
168+
fix_rounding(Target, Cols) ->
169+
Widths = lists:map(fun({Width, _Weight, _Idx}) -> Width end,
170+
Cols),
171+
SumWidths = lists:sum(Widths),
172+
shrink_widest(Target, SumWidths, Widths, Cols).
173+
174+
%% Determine whether our target table width is wider than the terminal
175+
%% due to any rounding error and find columns eligible to be shrunk.
176+
shrink_widest(Target, Current, Widths, _Cols) when Target =< Current ->
177+
Widths;
178+
shrink_widest(Target, Current, Widths, Cols) ->
179+
Gap = Current - Target,
180+
NonZeroWeighted = lists:dropwhile(fun({_Width, 0, _Idx}) -> true;
181+
(_) -> false end,
182+
Cols),
183+
shrink_widest_weighted(Gap, NonZeroWeighted, Widths).
184+
185+
%% Take the widest column with a non-zero weight and reduce it by the
186+
%% amount necessary to compensate for any rounding error.
187+
shrink_widest_weighted(_Gap, [], Widths) ->
188+
Widths; %% All columns constrained to fixed widths, nothing we can do
189+
shrink_widest_weighted(Gap, Cols, Widths) ->
190+
SortedCols = lists:sort(
191+
fun({WidthA, _WeightA, _IdxA}, {WidthB, _WeightB, _IdxB}) ->
192+
WidthA > WidthB
193+
end, Cols),
194+
{OldWidth, _Weight, Idx} = hd(SortedCols),
195+
NewWidth = ?MINWIDTH(OldWidth - Gap),
196+
replace_list_element(Idx, NewWidth, Widths).
197+
198+
%% Replace the item at `Index' in `List' with `Element'.
199+
%% Zero-based indexing.
200+
-spec replace_list_element(non_neg_integer(), term(), list()) -> list().
201+
replace_list_element(Index, Element, List) ->
202+
{Prefix, Suffix} = lists:split(Index, List),
203+
Prefix ++ [Element] ++ tl(Suffix).
204+
129205
get_row_length(Spec, Rows) ->
130206
Res = lists:foldl(fun({_Name, MinSize}, Total) ->
131-
Longest = find_longest_field(Rows, length(Total)+1),
207+
Longest = find_longest_field(Rows, length(Total)+1),
132208
Size = erlang:max(MinSize, Longest),
133209
[Size | Total]
134210
end, [], Spec),
@@ -137,19 +213,19 @@ get_row_length(Spec, Rows) ->
137213
-spec find_longest_field(list(), pos_integer()) -> non_neg_integer().
138214
find_longest_field(Rows, ColumnNo) ->
139215
lists:foldl(fun(Row, Longest) ->
140-
erlang:max(Longest,
141-
field_length(lists:nth(ColumnNo,Row)))
142-
end, 0, Rows).
216+
erlang:max(Longest,
217+
field_length(lists:nth(ColumnNo,Row)))
218+
end, 0, Rows).
143219

144220
-spec max_widths([term()]) -> list(pos_integer()).
145221
max_widths([Row]) ->
146222
field_lengths(Row);
147223
max_widths([Row1 | Rest]) ->
148224
Row1Lengths = field_lengths(Row1),
149225
lists:foldl(fun(Row, Acc) ->
150-
Lengths = field_lengths(Row),
151-
[max(A, B) || {A, B} <- lists:zip(Lengths, Acc)]
152-
end, Row1Lengths, Rest).
226+
Lengths = field_lengths(Row),
227+
[max(A, B) || {A, B} <- lists:zip(Lengths, Acc)]
228+
end, Row1Lengths, Rest).
153229

154230
-spec row(list(), list(string())) -> iolist().
155231
row(Spec, Row0) ->

0 commit comments

Comments
 (0)