The laundry list of things to do in Dynamo Browse has grown over the last week, as I find myself wanting using it and wanting more from it. I’ve knocked off many of the small ones: fixing bugs, making it easier to get the first item from a result set. The time has come to tackle some of the larger ones.
The first is the ability to annotate fields in the item view, as in add additional information to the right of the value. I would personally find that useful for describing the value of an other table using it’s ID. But the point is that it can be anything. I’d like users to add these annotations via extensions, using UCL. The way I’m thinking of doing this is by “installing” a field annotator, something along the lines as follows:
ui:add-item-annotator { |rs attr_path|
return...
The laundry list of things to do in Dynamo Browse has grown over the last week, as I find myself wanting using it and wanting more from it. I’ve knocked off many of the small ones: fixing bugs, making it easier to get the first item from a result set. The time has come to tackle some of the larger ones.
The first is the ability to annotate fields in the item view, as in add additional information to the right of the value. I would personally find that useful for describing the value of an other table using it’s ID. But the point is that it can be anything. I’d like users to add these annotations via extensions, using UCL. The way I’m thinking of doing this is by “installing” a field annotator, something along the lines as follows:
ui:add-item-annotator { |rs attr_path|
return ""
}
This will add an annotator which will take a result set, and a path to an attribute. It will return the annotated value. We’ll see how well this will work, but first I’ll need to build the Go types. Rendering the item view is done using an ItemRenderer which basically walks through the attributes of an item and renders it as a table. I think I’ll change this to take an item annotation. I also realised I need a type to represent the attribute path. I think a simple linked list would work here. Here’s what I got:
type AttrPathNode struct {
Name string
Index int
IsIndex bool
Parent *AttrPathNode
}
The trouble is representing both string attribute names and numerical indicies in the same type. Go doesn’t have a great way of doing this, so I’ve gone with something simple and added boolean which would be true if the node is actually an integer index to a list or set.
Actually, that may not be necessary. Poking around the renderer code, I found this type used to represent the sub items of an item:
type SubItem struct {
Key string
Value Renderer
}
So I can simply use strings for the key. Good to know. Okay, added a test annotator and gave it a quick test: Not bad, although found some glaring issues. It would be nice if the annotation was closer to the value. And laying out the annotations in a dedicated column will cause the entire column to shift as I move through the items. So instead of making it a separate column, I’ll try simply concatenating it to the end of the value. I’ll also apply the meta-info styling to dim the text a little: That’s much better. I’m banking on only a few attributes having annotations, so I’m hoping it won’t be as busy as it looks here.
Now to consider the UCL integration. Hmm, how am I going to connect the two? As expected, injecting the annotation directly into ItemRenderer would introduce a dependency loop. What I could do instead is add a setter allowing the caller of the ItemRenderer to change the annotation on the fly, and inject the ItemRenderer as a dependency of the command controller.
Implemented this, and it was actually easier than I thought. Now to test this. I settled on the following UCL command:
ui:set-item-annotator { |rs item path|
"annotation"
}
where:
rsis the result setitemis the item being rendered in the viewpathis the attribute path, represented as a list.
The attribute path is a new proxy, just to reduce the amount of memory copying between Go and UCL. I kept the same list index semantics, where > 0 starts from the left, and < 0 starts from the right, but it was a little mind bending to reverse this for a linked list.
A simple annotator which returns the currently displayed item value as an annotation can be implemented as follows:
ui:set-item-annotator { |rs item path|
$item.($path.(-1))
}
Here it is in action: Brilliant, it’s working! Naturally as commands are simple UCL statements, this could be entered on the command line.
One thing to consider is that unlike normal commands, the UCL block rendering the annotation is running on the UI thread. Not sure I like this, but the amount of effort required to change this would be significant. I guess I’ll just tell everyone to keep annotation rendering fast. And as it’s currently implement, it is reasonably fast. Or more accurately, it’s not noticeably slow, which is good enough.
The next feature to add to Dynamo Browse is a way to asynchronously schedule blocks. I think it may be worth tapping into the existing command looper in some way.
Commands in Dynamo Browse are invoked on a dedicated goroutine. Calling execute() will attempt to send a command via a channel. If that fails, execute() will return an error indicating that a command is currently running. This keeps running UCL code off the UI thread, and it also makes it possible to implement commands like ui:prompt synchronously from the UCL code’s perspective, when in reality it pauses this goroutine and sends a message to the UI with a callback to resume the thread:
func (m *uiModule) uiPrompt(
ctx context.Context,
args ucl.CallArgs,
) (any, error) {
var prompt string
if err := args.Bind(&prompt); err != nil {
return nil, err
}
resChan := make(chan string)
cancelChan := make(chan struct{})
go func() {
commandctrl.PostMsg(ctx, events.PromptForInputMsg{
Prompt: prompt,
OnDone: func(value string) tea.Msg {
resChan <- value
return nil
},
OnCancel: func() tea.Msg {
cancelChan <- struct{}{}
return nil
},
})
}()
select {
case value := <-resChan:
return value, nil
case <-cancelChan:
return nil, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
So tapping into this event loop would be nice. But how does one do so?
I think, probably the simplest way to do so is with buffered channels. These are bounded, meaning that there will be an upper limit to the number of pending tasks, but maybe that’s not a bad thing. Having too many pending tasks crowding out the user’s ability to run commands will probably make for a poor user experience. So let’s set the limit to something quite generous, say 50, and integrate it into the event loop:
for {
select {
case cmdChan := <-c.cmdChan:
res, err := c.ExecuteAndWait(ctx, cmdChan.cmd)
if err != nil {
c.postMessage(events.Error(err))
} else if res != nil {
c.postMessage(events.StatusMsg(fmt.Sprint(res)))
}
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
// New code here
case task := <-c.pendingTaskChan:
if err := task.task(ctx); err != nil {
c.postMessage(events.Error(err))
}
}
}
Now for the UCL API. I have an idea of adding a new async package for this, which will provide commands for running things asynchronously. It’s simplest command would be async:do, which will schedule a block when the command loop is free. So invoking the following:
ui:command testasync {
echo "This"
async:do {
echo "Other"
async:do { echo "Sierra" }
}
async:do {
echo "Romeo"
}
echo "That"
}
Should display:
This
That
Other
Romeo
Sierra
In the logs. Trying it out and here’s how it looks: Okay, that’s pretty good. Now to build on this. The next thing to add is a command that will schedule tasks in the future, similar to window.setTimeout(). For this, I plan to use the gocron package. Lots there, but for my purpose, I plan to use the OneTimeJob type. It does feel like bringing in a crane to lift an empty wood pallet, but the alternative is building my own scheduler (or getting AI to vibe-code one) using a heap. Maybe something for later, but I think this is fine for now.
To make use of this, I’ll add async:in, which takes a timeout in seconds, and a block to run. To test this, I’ll do the classic “count down” tests:
proc countdown { |from|
if (le $from 0) {
echo "Blast off!!"
return
}
echo "$from"
async:in 1 { countdown (sub $from 1) }
}
ui:testasync {
countdown 10
}
Here it is in action:
One last thing to add to async, the ability to run a query in the background and execute a block once the results are available, keeping long running queries off the UCL goroutine. I think for this I will add another thread-pool which will execute up to two queries in the background, then schedule a task to run once the results are ready.
The API for running queries is pretty well established, to the point that I’ve actually got a helper functions which deal with the UCL side of things, so it’s just a matter of implementing an asynchronous version of this. I’m thinking of an interface along the following lines:
async:query <query> { |rs|
# do something with the result-set $rs
}
It should also support keyword arguments too. Unfortunately, UCL requires keyword arguments to be placed after the positional arguments, so they would need to be placed after the block, which is a little yucky.
Implemented the code, now for the test. This one’s a little contrived. What it does is asynchronously run a query on startup, counting the number of opened and closed offices in the business-addresses table. Once those results are available, it will install a new column annotation, which will display the count to the right of the officeOpened fields.
proc _prep_officeCount {
openedOffices = 0
closedOffices = 0
async:query 'officeOpened=true' { |rs|
openedOffices = len $rs
async:query 'officeOpened=false' { |rs|
closedOffices = len $rs
ui:set-item-annotator { |rs item path|
if (eq $path.(-1) "officeOpened") {
if $item.officeOpened {
"Count = ${openedOffices}"
} else {
"Count = ${closedOffices}"
}
} else {
""
}
}
} -table business-addresses
} -table business-addresses
}
_prep_officeCount
It is contrived, but I’m hoping to use this in a real setting where I annotate ID fields with a value I retrieve from another table, so a use case like this is pretty close to why I built this feature. Because the queries are running in the background, the goal is to avoid blocking the UI thread, but since this runs on startup before the user selects a table, the table needs to be specified as part of the call. I may have to have a think of how I am to fix that.
But here it is working. Note the Count = 244 next to the officeOpened field:
Excellent. The next thing to do is try this out in a real setting. I finished off making a proper goroutine pool for the task that actually runs the query — which I’m calling the “aux task pool” — and a few other changes, like making the meta information a little easier to see.