Saturday, February 7, 2015

MonoMac #3: Creating a prompt dialog

One of my most important pieces of advice if you're creating a Xamarin codebase is to pay especially good attention to separation of concerns. In a best case, you'll want to reuse everything but the UI when you go from app to app and platform to platform.

I do this by first creating a project with all of my "business logic", and I perform testing via a minimal command line application that'll behave the same regardess of the platform you're running on. This makes it easy for me to develop an app for any platform on any platform, which is great. And by "any", I do mean "any" -- you can run Mono with MonoDevelop on Linux and develop this headless + command line app. That's kind of neat (and also forces you to separate concerns).

Console utility functions

I have a utility class that has some static methods in it that help me to this.

Quick hint: We're going to use Console.WriteLine() in this example, but when you're playing with the command line, it's often more useful to do something like this:

public static void WriteAtLine(int intLine, string strToWrite)
{
   ClearLineSetCursor(intLine);
   Console.Write(strToWrite);
}
public static void ClearLineSetCursor(int intRow)
{
  Console.SetCursorPosition(0, intRow);
  Console.Write(new String(' ', Console.BufferWidth));
  Console.SetCursorPosition(0, intRow);
}
The real "trick" is the SetCursorPosition line, where you set your cursor and clear out a line like you would waaaay back in the day, when you were writing for a dumb terminal. It's surprising how much thought goes into making a good command line client, even if you're the only one using it.

Here's one of my most active ones, as it's what prompts for and grabs data from the user:

public static string PromptAndResponse(string strPrompt, 
    bool isPassword = false)
{
    string strReturn = string.Empty;
    Console.Write(strPrompt);

    if (isPassword)
    {
        ConsoleKeyInfo conkeyInfo;
        do
        {
            conkeyInfo = Console.ReadKey(true);
            strReturn += conkeyInfo.KeyChar;
            Console.Write("X");
        } while (ConsoleKey.Enter != conkeyInfo.Key);
    }
    else
    {
        strReturn = Console.ReadLine();
    }

    return strReturn.TrimEnd();
}

Porting your console app to a Mac GUI

So this is my first port to Mac. With my simpler mobile apps, I just started from scratch, and hooked up my business logic without any real porting. But on one of my larger projects -- an email client -- I figured it was worth giving a true porting of my command line client a shot.

But what do you do to stub out these utility functions while you're mid port? You certainly don't to take in input from the command line in a serious Frankenstein's App interface, do you?

This isn't much better, but after looking through this answer on StackOverflow, I made a javascript prompt style utility function to replace PromptAndRepsonse on the command line.

Now this said, I do completely agree with this comment on that StackOverflow question, which says, "There isn't a predefined method for that because that's bad UI. That should just be a field." This is a simple stand-in while you're really coding your UI, okay?! Promise?!?

Here we go. I'm going to just slap it into MainWindow.cs as a static method to make things easy to run, but you'd want to factor it somewhere else. This is nothing but a port of the code from the above StackOverflow answer:

public static string PromptAndResponse(string msg)
{
    string strReturn = string.Empty;

    NSAlert alert = new NSAlert ();
    alert.MessageText = msg;
    alert.AddButton ("Ok");
    alert.AddButton ("Cancel");

    NSTextField input = new NSTextField (new Rectangle (0, 0, 200, 24));
    input.StringValue = string.Empty;
    alert.AccessoryView = input;

    int intSelectedButton = alert.RunModal ();
    if (intSelectedButton.Equals((int)NSAlertButtonReturn.First))
    {
        strReturn = input.StringValue;
    }

    return strReturn;
}

Only one thing really bugged me there. Check out the description of the NSAlertButtonReturn enum from Apple's docs.

The enum is wacky. It goes to three (1000, 1001, 1002), then you're on your own. Cocoa counting cultural level: One, two, three, many. Why use an enum at all? Anyhow, let's keep up with the sample code and use it.

I guess I also feel there's got to be an cleaner way to get to 1000 from ~First than a cast. When I have it paused, I can use .Value, but that doesn't exist (?) at compile time.

Example usage

To use this code in an example, simply add it somewhere in your MainWindow.cs file, and then insert this AwakeFromNib function to that same file:

public override void AwakeFromNib ()
{
    int intButtonHeight = 25;
    int intButtonWidth = 120;

    int intFullHeight = (int)this.ContentView.Frame.Size.Height;

    NSButton cmdGo =  new NSButton(new Rectangle (
        20, intFullHeight - 50,
        intButtonWidth, intButtonHeight
    ));
    cmdGo.Title = "Send/Receive";
    cmdGo.BezelStyle = MonoMac.AppKit.NSBezelStyle.Rounded;
    cmdGo.Activated += (object sender, EventArgs e) => {
        string strReply = MainWindow.PromptAndResponse(
            "Enter some text, please."
        );
        Console.WriteLine(strReply);
    };
    this.ContentView.AddSubview (cmdGo);
}

This is the most aggravating part of Apple's "(0,0) is at the bottom left corner" coordinate system we mentioned last time, and will mention again in the next step. If I want to place a button at the top left, I have to start calculating against the NSWindow's height. And, extra annoyingly, I don't know how to get the Window's height with the titlebar yet (I'm just grabbing the ContentView's height here). /sigh So I'm pretending it's about 30 until I stumble over a better source for the height.

And run it with Command-Return. Voila. Pretty exciting.

Now to really mess with yourself, resize the window. Make it smaller. Now quit. Now reopen. Huh? We'll start dealing with adding UI widgets more deliberately next time. But for now, we have an easy stand-in for our command line apps while we're porting each function over.