Loading and Saving Files in Visual Basic (Matt Gertz)

Loading and Saving Files in Visual Basic (Matt Gertz)

  • Comments 9

(This is part 3 of the Paint-by-Numbers series)

There are four issues I want to deal with in this post:  New, Open, Save (As), and Exit.  I’ll be doing these in order, but all of them depend on knowing whether or not the application is dirty, so I’ll deal with that problem first.  Opening and saving files isn’t a particularly hard problem once you know how to deal with streams, but knowing when to notify users about potential data loss when opening files (or creating new ones, or closing an application) involves some logic, and that's where understanding "dirtiness" comes into play. 

First, however, I’m going to deal with a problem that Bill McC brought up with my last post (see the comments attached to my last post for details).  I was essentially abusing List Of() to be a stack, when Stack Of() already exists.  This illustrates why it’s really important to (a) have a solid plan that you either stick to or re-examine thoroughly if you decide to change course and (b) make sure that you get someone to review your code.  None of the code that I write for these blogs gets reviewed by anyone else -- it’s just stuff that I find time to do in the cracks of my schedule – but of course for production code in Visual Studio, every line gets scrutinized deeply by a senior developer (or two, when we get closer to shipping) for just this reason.  In my case, my original plan was to append the undo units to a list, which would be fine, but I changed my mind halfway through the coding of undo/redo to inserting them at the front instead without careful consideration of the performance hit that would create.

Changing this to use stacks instead of lists was very trivial:

-          I replaced all “List Of(…)“ with “Stack Of(…)”

-          I replaced all “Insert(0,…)” with “Push(…)”

-          I replaced all “Remove(0,…)” with “Pop(…)”

-          I replaced all “Item(0)” with “Peek()”

 

Functionality-wise, that’s all that needs to be done and then everything will work the way it did before, albeit in a more performant manner, but I also made the following changes:

 

-          I right-clicked on the UndoList and RedoList variables and used the “rename” command to change them to “UndoStack” and “RedoStack.”  This is not a functional change, but I like my variable names to match my types.

-          I eliminated an unnecessary temporary variable in the Undo and Redo commands by nesting the calls to the stack, so that now Undo is:

        If CanUndo() Then

            DoActions(UndoStack.Peek(), True) ' Do all the actions inverted (erase becomes draw and vice-versa)

            RedoStack.Push(UndoStack.Pop()) ' Now it's a redo

' (etc…)

 

and similarly for Redo:

        If CanRedo() Then

            DoActions(RedoStack.Peek(), False) ' Do all the actions properly (erase is erase, draw is draw)

            UndoStack.Push(RedoStack.Pop()) ' Now it's an undo

' (etc…)

 

(Keen eyes will note that I now do the Actions before moving the action item… that’s not strictly necessary, but is more aesthetically pleasing to me.)

Those changes took about three minutes total and result in a much more performant and elegant application.

Now, on to the main topic for today – loading and saving.

The Dirty Bit

Most applications have something called a “dirty bit” which gets set when an action happens in the document, and if you try to close the application while it’s dirty, you get prompted to save.  There are expedient ways to implement this, and smart ways which take a little more work:

(1)    Do nothing.  That is, whenever the user saves, you always overwrite the file regardless of whether or not anything has changed, and you always prompt the user to save when closing the application regardless of the applications state.  I don’t know about you, but applications like this always annoy the heck out of me.

(2)    Create a Boolean value which gets set whenever any action happens, and gets cleared once a save is committed – check this value to see whether or not a save is needed.  This is better, and a lot of applications do this.  However, if you support undo/redo, then the simple act of doing an undo followed by a redo after a save will result in a “dirty” app, even though the document is identical to the copy on disk.

(3)    Compare the top of the undo stack with a known “last action,” and use that as the “dirty” flag.  Thus, an undo followed by a redo gets you back to the same state, with no unnecessary prompt to save.  It involves slightly more logic, but it gives the truest indication as to whether or not anything has really changed.

I’m going to implement option (3).  To do this, first I need to cache the last pen stroke before the save:

    Dim LastSavedAction As Stack(Of GridAction) = Nothing

and then check the value of that against the top of the Undo stack (or against Nothing if the UndoStack is empty):

    Private Function IsDirty() As Boolean

        If Me.UndoStack.Count = 0 Then

            Return LastSavedAction IsNot Nothing

        Else

            Return LastSavedAction IsNot Me.UndoStack.Peek()

        End If

    End Function

 

If the document is not dirty, then the top of the undo stack will have to be whatever we cached it to be at the last save; otherwise, it’s dirty.  (When starting up, the Undo stack will be empty and the LastSavedAction will be Nothing, so that combination should indicate that we’re not dirty as well.)

We’ll add a helper to disable the Save menu item:

    Private Sub UpdateSaveMenuItem()

        Me.SaveToolStripMenuItem.Enabled = IsDirty()

    End Sub

 

which we can call anytime something interesting happens – at the end of the handlers for FormLoad, MouseUp, Undo, Redo, and of course New, Open, Save and Save As.   I’ll go ahead & call the UpdteSaveMenuItem helper function from the first four of those – I’ll do the others later when I write those routines.

New

Opening up the form editor, I’ll drop-down the “File” menu and double-click on “New” – this will create the appropriate handler for me.  Now, all New needs to really do is just reset everything to the way it was when the application was started, but first it needs to check to see if the user really wants to discard any changes since the last save.  In my resource editor (right-click “Project,” choose “Properties,” and then navigate to the Resources tab), I’ll add a string called MSG_SaveFirst which says “Do you want to save first?” and I’ll add a helper called ContinueOperation to prompt the user with this string (Cancel will cancel the operation, Yes will save the puzzle before proceeding, and No will just proceed with the operation).  Then, the methods look like:

    Private Function ContinueOperation() As Boolean

        If IsDirty() Then

            Dim result As MsgBoxResult = MsgBox(My.Resources.MSG_SaveFirst, MsgBoxStyle.YesNoCancel)

            If result = MsgBoxResult.Cancel Then

                Return False ' User decided not to create a new document after all

            ElseIf result = MsgBoxResult.Yes Then

                If String.IsNullOrEmpty(saveFileName) Then

                    Return DoSaveAs()

                Else

                    Return DoSave(saveFileName)

                End If

            End If

        End If

        Return True

    End Function

 

    Private Sub NewToolStripMenuItem_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) _

Handles NewToolStripMenuItem.Click

        If Not ContinueOperation() Then

            Return 'User elected to Cancel

        End If

 

        ' Now, reset everything to its pristine state:

        For i As Integer = 0 To Rows - 1

            For j As Integer = 0 To Columns - 1

                Grid(i, j) = 0

            Next

        Next

        UndoStack.Clear()

        RedoStack.Clear()

        CurrentDraw = Nothing

        LastSavedAction = Nothing

        Me.UpdateSaveMenuItem()

        Invalidate()

    End Sub

 

Don’t worry about what "DoSave" or "DoSave As" do yet… I’ll discuss those later on.  For now, just assume that they will save the document appropriately if the user chooses “yes” from the message box.

Open

Again, I’ll double-click on the item in the form editor to create the handler for Open.  Its code starts out and ends up very similar to that of New (checking to see if the user really wants to discard any changes, resetting stacks, etc), but in the middle it pops up a file load dialog to retrieve the file name and subsequently loading the file.  I’ll need to drag a OpenFileDialog to the form (it will show up in the grey area beneath the form, since it’s not location-specific to the form), and then I can call it to get the file name.  I’ll need to specify the type of file that I’ll be loading – a *.vbpbn file – so I’ll create another resource called FILT_FileFilter which contains the string “VB Paint-by-Numbers Files (*.vbpbn)|*.vbpbn”.  The text before the pipe (|) character is what the user will see in the dialog’s drop-down, and the text after it is what the dialog will use to check for applicable files. 

Now, unlike New, I have to actually read some values into the grid instead of just setting them all to zero.  When I open the file, what I get back is a stream from which I need to read some number of bytes.  In my case, the file format is simply a binary list of the values for the grid, which each row listed in order, so that the total number of bytes stored is simply (Rows * Columns).  The result is a single-dimensional array of bytes, and I need to “deserialize” that into my two-dimensional array.  (If I decided to allow my puzzle grids to be variably-sized, I’d have to persist out the number of rows and columns for a given puzzle in the first two values, read them from the stream of bytes first, and then size the grid appropriately before deserializing the grid itself, but for the purposes of this exercise we’re assuming that Rows and Columns are fixed, known values.)  If I have problems opening the file of reading from the stream, I might have an exception thrown, so I’ll put in a Try-Catch to cover that eventuality, with a Finally added also to ensure that, no matter what happens, the file stream gets closed.  So, with the beginning and end of the code identical to that of “New,” the middle part of the code becomes:

        ' Get the file name:

        VBPBNOpenFileDialog.Filter = My.Resources.FILT_FileFilter

        If VBPBNOpenFileDialog.ShowDialog() = Windows.Forms.DialogResult.OK Then

            Dim gridStream As IO.Stream = Nothing

            Try

                gridStream = VBPBNOpenFileDialog.OpenFile()

                If gridStream IsNot Nothing Then

                    ' Read stream here:

                    Dim b(Rows * Columns) As Byte

                    gridStream.Read(b, 0, Rows * Columns)

 

                    ' Deserialize it to the grid

                    For i As Integer = 0 To Rows - 1

                        For j As Integer = 0 To Columns - 1

                            Grid(i, j) = b(i * Rows + j)

                        Next

                    Next

 

                End If

            Catch ex As Exception

                Dim s As String

                s = String.Format(My.Resources.MSG_OpenFailed, ex.Message)

                MessageBox.Show(s)

                Return ' Don't mangle the current puzzle

            Finally

                ' Make sure stream gets closed.

                If (gridStream IsNot Nothing) Then

                    gridStream.Close()

                End If

            End Try

 

where MSG_OpenFailed is another resource string defined as “Unable to open the file: error returned was: {0}” – the {0} will be replaced with the error code in the Format() call.

Now, one thing to notice is my use of file space.  As I noted in the first post in this series, I’m actually using 8 times more memory than I need to for the grid, since each grid pixel is using a full byte where a single bit would do, and so correspondingly I’m using 8 times more disk space that I really need.  The fact that I’m only dealing with a mere 24 rows & 24 columns means that I’m willing to part with that extra memory in return for more elegance of coding.  However, if I was truly hard core on performance, each of the 24 rows would only have 3 bytes, and an entire puzzle would take only 72 bytes instead of 576 bytes.  I’d fix all of that by creating Set() methods to flip specific bits – then Open (and Save) would get this for free since they just act on the array as a while and not on individual bits.  If I was *truly* hard core, I’d skip using a two-dimensional array altogether in preference for a one-dimensional array with calculated offsets into each “row,” which I could stream directly into the file without needing to serialize or deserialize. (In fact, it was actually this very question of changing bits in a two-dimensional picture represented by a one-dimensional array that was the first Microsoft interview question I was ever asked, nearly 13 years ago at a job fair.  It certainly is efficient memory-wise, and everyone should know how to do it in case you really do need to be miserly about memory, but I’ll favor readability in this case.)

Save and Save As

These require very similar handlers to each other.  The big difference is that Save will use the last saved file location if any, whereas SaveAs will always pop up a dialog.  I’m going to introduce a String to track the last saved name:

    Dim saveFileName As String

I will set it to the filename after a successful Open, Save, or SaveAs, and will clear it on New (assume that I’ve done that in the code for New and Open; I’m not going to write it out here).  I’ll also add a SaveFileDialog to my form, and double-click the Save and Save As menu items to create the handlers.

Since both Save and SaveAs can pop up a dialog, I’ll create a helper function to do the persistence – it takes a file name as an argument.  The method creates a stream based on the file name and fills it with data from the grid, and then closes it to flush it to disk.  The code which does the actual file writing needs to serialize the grid into a single array of bytes (again, at this point I’d also serialize out the number of rows and columns at the beginning if such could be variable) before writing it out to the file’s stream, and just as in the case of Open, I have a try-catch-finally structure to deal with problems opening or writing to the file.   Then the LastSavedAction is set to whatever is on top of the Undo stack (if anything), which flushes the “dirty” state of the puzzle document.

    Private Function DoSave(ByVal FileName As String) As Boolean

        Dim gridStream As IO.Stream = Nothing

        Try

            gridStream = IO.File.Open(FileName, IO.FileMode.OpenOrCreate, IO.FileAccess.Write)

            If gridStream IsNot Nothing Then

                ' Write stream here:

                Dim b(Rows * Columns) As Byte

 

                ' Serialize the grid from the stream

                For i As Integer = 0 To Rows - 1

                    For j As Integer = 0 To Columns - 1

                        b(i * Rows + j) = Grid(i, j)

                    Next

                Next

 

                ' And write it out

                gridStream.Write(b, 0, Rows * Columns)

 

            End If

        Catch ex As Exception

            Dim s As String

            s = String.Format(My.Resources.MSG_SaveFailed, ex.Message)

            MessageBox.Show(s)

            Return False ' Don't mangle the current puzzle

        Finally

            ' Make sure stream gets closed.

            If (gridStream IsNot Nothing) Then

                gridStream.Close()

            End If

        End Try

 

        If UndoStack.Count > 0 Then

            LastSavedAction = UndoStack.Peek()

        Else

            LastSavedAction = Nothing

        End If

        saveFileName = FileName

        Me.UpdateSaveMenuItem()

        Return True

 

    End Function

Note that DoSave only returns True if we actually saved data – this will keep us from losing the current puzzle if the save is part of a reaction to New/Open/Close commands which leverage the ContinueOperation helper function. 

Sometimes, Save will have to do a Save As (if it’s a new puzzle, for example), so I’ll create a SaveAs helper which pops up the save dialog and then leverages the previous helper:

    Private Function DoSaveAs() As Boolean

        ' Get the file name:

        VBPBNSaveFileDialog.Filter = My.Resources.FILT_FileFilter

        If VBPBNSaveFileDialog.ShowDialog() = Windows.Forms.DialogResult.OK Then

            Return DoSave(VBPBNSaveFileDialog.FileName)

        Else

            Return False

        End If

    End Function

 

Now, we can easily define Save and Save As.  Save will check to see if the document already has a filename – if it does, it calls DoSave, otherwise it calls DoSaveAs.  SaveAs calls the DoSaveAs always.

    Private Sub SaveToolStripMenuItem_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) _

Handles SaveToolStripMenuItem.Click

        If String.IsNullOrEmpty(saveFileName) Then

            DoSaveAs()

        Else

            DoSave(saveFileName)

        End If

    End Sub

    Private Sub SaveAsToolStripMenuItem_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) _

Handles SaveAsToolStripMenuItem.Click

        DoSaveAs()

    End Sub

 

Closing the Application

We need to give the user a chance to save any unsaved data when closing the application.  This is very similar to what we’ve already done for New and Open:

    Private Sub VBPBNForm_FormClosing(ByVal sender As Object, ByVal e As System.Windows.Forms.FormClosingEventArgs) _

Handles Me.FormClosing

        e.Cancel = Not ContinueOperation()

    End Sub

 

Setting e.Cancel to True will abort the close, and we’ll do that if ContinueOperation indicates that the user didn’t really mean to close the app (that is to say, if ContinueOperation returns False – “Closing” is the operation in this case).  As a rule, I hate inverse logic, but this will work fine here.  (Don’t forget to have the “Exit” command under the file menu call “Me.Close” – otherwise, it won’t do anything as it’s not automatically hooked up.)

Feedback to the User

We’re almost done.  The functionality is there, but it would be nice to indicate to the user whether or not the document has a name and whether or not it’s dirty.  We do this by updating the title bar as things change.  Since we know that the title bar would change every time the Save menu item status changes, we can insert some code for this in UpdateSaveMenuItem (which I’ll rename to UpdateSaveMenuItemAndTitleBar, using right-click “Rename” to update all of the instances):

    Private Sub UpdateSaveMenuItemAndTitleBar()

        ' Make sure the menu item is correct

        Me.SaveToolStripMenuItem.Enabled = IsDirty()

 

        ' Now construct the title:

        Dim title As String = saveFileName

        If String.IsNullOrEmpty(title) Then

            title = My.Resources.TITLE_UnknownPuzzle

        End If

 

        If IsDirty() Then

            Me.Text = String.Format(My.Resources.TITLE_TitleBar, title, "*")

        Else

            Me.Text = String.Format(My.Resources.TITLE_TitleBar, title, "")

        End If

    End Sub

 

Where TITLE_TitleBar is defined in the resources to be “{0} - Visual Basic Paint-by-Numbers {1}” and TITLE_UnknownPuzzle is “Untitled Puzzle”.

Onward…

That wraps up the new/open/save/close work for this application.  Next time, I’ll close out the series by talking about printing, and then will head out for a looooong vacation.  ‘Til then…

--Matt--*

Leave a Comment
  • Please add 6 and 4 and type the answer here:
  • Post
  • Matt,

    Again, very nice example. Just a couple of questions.

    In the NewToolStripMenuItem handler, you looped through and set Grid(I,J) = 0. Would doing a ReDim Grid(Rows,Columns) achieve the same thing?

    Next, for simple strings, like the various MSG_items you used, what is the difference between using the project Resources to store them versus using the project Settings to store them?

    And last, the IsDirty method catches a simple Undo/Redo, but it misses when a user Opens a saved puzzle, Shift Clicks a checked square and then Clicks it again. The puzzle ends up in the original state but is now dirty. Would it be better to store a hash (or similar) of the last saved/loaded Grid object and actually detect if the Grid array has the same bits flipped?

    Thanks,

    Aaron

  • Hi, Aaron,

     Thanks for the comments!  I'm glad you're enjoying the series.  I've finished the final one and am just letting it bake before I post it.  

    So, in order then:

    (1) Yes, you could use ReDim.  However, ReDim works by releasing the current memory and allocating new memory which it then initializes with the default value (assuming that you don't use "Preserve") -- since the array is going to have to be set to zero anyway, I've saved the memory hit.  HOWEVER, if you allowed the user to change the size of the grid, ReDim would be very useful in that case.

    (2) Resources are essentially constants for a given locale; things that the program needs which you might want to localize into a different language but otherwise stay the same.  Settings are things that the user modifies that need persistence (game options, for example), and so wouldn't make sense to use in this context.  See my "Euchre game" blog series for an example of how settings work (http://blogs.msdn.com/vbteam/archive/2007/03/23/coding-a-euchre-game-part-8-remember-me-show-me-help-me-matt-gertz.aspx).

    (3) Sure, you could do that if you were really hard core, but to me that's "diminishing returns."  It would be more logic/memory to add & maintain for an occurance that likely no customer cares about.  In fact I've never seen a application that does that sort of check around comparisons to a previous state.  Personally, I'd expect undo to return me to a previous state, but not arbitrary design work.  (Your mileage may vary...)

    --Matt--*

  • (1) What about Array.Clear(Grid,0,Grid.Length)?

  • OK here's the deal. If you are teaching Visual Basic, programming in Visual Basic for fun or profit

  • OK here's the deal. If you are teaching Visual Basic, programming in Visual Basic for fun or profit,

  • ,,can you give some example of a database of a student prospectus?using visual basic?

  • i need a coding for save command button in visual basic

  • I am having some trouble saving all the files into the folder that I want, because I only have the option to save all and than copy and paste all the files into the folder that I want

  • Great job mate!

    I love this!

    Il meet you for some dank later okay?

Page 1 of 1 (9 items)