Wednesday, October 9, 2024

How to make Text Boxes ReadOnly when their container is Disabled

WPF has a feature that whenever a container is disabled, all its controls are disabled too. We use this feature to easily and reliably disable entire pages with a single binding.

<Grid IsEnabled="{Binding IsEditable}">
...
</Grid>

The problem is that some controls such as TextBoxes, DataGrids, and ListBoxes don't disable well because you cannot interact with them. These controls support IsReadOnly which prevents them from being modified but still allows interaction.

If only there was a way to stop TextBoxes from inheriting their parent's IsEnabled. Instead I would like the text box to become read only when the container is disabled. But if the text box has IsReadOnly bound, the container needs to honor that to.

Here it is...

Start a new Visual Studio project called IsReadOnlyTextBox using Visual Basic, .Net Framework.
Add a new class called CustomGrid and another called CustomTextBox. For a full implementation of this feature we need to subclass all potential containers and all potential controls.

Let's start with the control. We need to break the inheritance of IsEnabled and also intercept changes to IsReadOnly.

    Shared Sub New()

        IsEnabledProperty.OverrideMetadata(GetType(CustomTextBox),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))

        IsReadOnlyProperty.OverrideMetadata(GetType(CustomTextBox),
                                            New FrameworkPropertyMetadata(
                                                defaultValue:=False,
                                                propertyChangedCallback:=AddressOf IsReadOnlyChanged,
                                                coerceValueCallback:=AddressOf IsReadOnlyCoerced
                                          ))
    End Sub

This allows us to intercept changes to IsReadOnly but we need to know why it was changed. Was it changed because of a binding or because the container was disabled? If it was changed because of the binding we need to store the new value so if the container is re-enabled we can set the text boxes IsReadOnly back to its bound value.

The whole CustomTextBox class looks like this.

Public Class CustomTextBox
    Inherits TextBox

    Public Property IsReadOnlyBackup As Boolean = False
    Public Property IsSetting As Boolean = False

    Shared Sub New()

        IsEnabledProperty.OverrideMetadata(GetType(CustomTextBox),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))

        IsReadOnlyProperty.OverrideMetadata(GetType(CustomTextBox),
                                            New FrameworkPropertyMetadata(
                                                defaultValue:=False,
                                                propertyChangedCallback:=AddressOf IsReadOnlyChanged,
                                                coerceValueCallback:=AddressOf IsReadOnlyCoerced
                                          ))
    End Sub

    Shared Sub IsReadOnlyChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
        Dim tb As CustomTextBox = TryCast(d, CustomTextBox)
        If Not tb.IsSetting Then
            Debug.WriteLine($"Setting IsReadOnlyBackup {e.NewValue}")
            tb.IsReadOnlyBackup = Convert.ToBoolean(e.NewValue)
        End If
    End Sub

    Shared Function IsReadOnlyCoerced(d As DependencyObject, value As Object) As Object
        Return value
    End Function

    Public Sub SetIsReadOnly(value As Boolean)
        IsSetting = True
        Debug.WriteLine($"SetIsReadOnly {value}")
        If value Then
            Me.IsReadOnly = True
        Else
            Me.IsReadOnly = IsReadOnlyBackup
        End If
        Debug.WriteLine($"IsReadOnly is now {Me.IsReadOnly}")
        IsSetting = False
    End Sub
End Class

We need to subclass the container, in this case Grid, to intercept changes to IsEnabled and apply the change to its children. It looks like this...

Public Class CustomTextBox
    Inherits TextBox

    Public Property IsReadOnlyBackup As Boolean = False
    Public Property IsSetting As Boolean = False

    Shared Sub New()

        IsEnabledProperty.OverrideMetadata(GetType(CustomTextBox),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))

        IsReadOnlyProperty.OverrideMetadata(GetType(CustomTextBox),
                                            New FrameworkPropertyMetadata(
                                                defaultValue:=False,
                                                propertyChangedCallback:=AddressOf IsReadOnlyChanged,
                                                coerceValueCallback:=AddressOf IsReadOnlyCoerced
                                          ))
    End Sub

    Shared Sub IsReadOnlyChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
        Dim tb As CustomTextBox = TryCast(d, CustomTextBox)
        If Not tb.IsSetting Then
            Debug.WriteLine($"Setting IsReadOnlyBackup {e.NewValue}")
            tb.IsReadOnlyBackup = Convert.ToBoolean(e.NewValue)
        End If
    End Sub

    Shared Function IsReadOnlyCoerced(d As DependencyObject, value As Object) As Object
        Return value
    End Function

    Public Sub SetIsReadOnly(value As Boolean)
        IsSetting = True
        Debug.WriteLine($"SetIsReadOnly {value}")
        If value Then
            Me.IsReadOnly = True
        Else
            Me.IsReadOnly = IsReadOnlyBackup
        End If
        Debug.WriteLine($"IsReadOnly is now {Me.IsReadOnly}")
        IsSetting = False
    End Sub
End Class

Here's some XAML that demonstrates how these two controls work together.

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:IsReadOnlyTextBox"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="Inheriting IsEnabled" SizeToContent="WidthAndHeight">
    <StackPanel Orientation="Vertical" Margin="10">
        <StackPanel Orientation="Horizontal">
            <CheckBox IsChecked="{Binding IsGridEnabled}" Content="Enable Grid"/>
            <CheckBox IsChecked="{Binding IsCheckboxEnabled}" Content="Checkbox is enabled" Margin="20,0,0,0"/>
            <CheckBox IsChecked="{Binding IsOldTextboxReadOnly}" Content="Old TextBox is Readonly" Margin="20,0,0,0"/>
            <CheckBox IsChecked="{Binding IsNewTextboxReadOnly}" Content="New TextBox is Readonly" Margin="20,0,0,0"/>
        </StackPanel>
        <Border Margin="10" Padding="10" BorderThickness="1" BorderBrush="Black">
            <local:CustomGrid IsEnabled="{Binding IsGridEnabled}">
                <Grid.RowDefinitions>
                    <RowDefinition Height="auto"/>
                    <RowDefinition Height="auto"/>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <CheckBox Grid.Row="0" Grid.Column="1" Content="A check box" IsEnabled="{Binding IsCheckboxEnabled, Mode=TwoWay}"/>
                <TextBox Grid.Row="1" Grid.Column="2"
                         Text="A regular old text box" Width="100"
                         IsReadOnly="{Binding IsOldTextboxReadOnly, Mode=TwoWay}"
                         HorizontalScrollBarVisibility="Auto"/>
                <local:CustomTextBox Grid.Row="1" Grid.Column="3"
                                     Text="A new custom text box" Width="100"
                                     IsReadOnly="{Binding IsNewTextboxReadOnly, Mode=TwoWay}"
                                     HorizontalScrollBarVisibility="Auto"/>
            </local:CustomGrid>
        </Border>
    </StackPanel>
</Window>

The code behind looks like this...
Imports System.ComponentModel
Imports System.Runtime.CompilerServices

Class MainWindow
    Implements INotifyPropertyChanged

    Private _IsGridEnabled As Boolean = True
    Public Property IsGridEnabled As Boolean
        Get
            Return _IsGridEnabled
        End Get
        Set(value As Boolean)
            SetProperty(_IsGridEnabled, value)
        End Set
    End Property

    Private _IsOldTextboxReadOnly As Boolean = False
    Public Property IsOldTextboxReadOnly As Boolean
        Get
            Return _IsOldTextboxReadOnly
        End Get
        Set(value As Boolean)
            SetProperty(_IsOldTextboxReadOnly, value)
        End Set
    End Property

    Private _IsNewTextboxReadOnly As Boolean
    Public Property IsNewTextboxReadOnly As Boolean
        Get
            Return _IsNewTextboxReadOnly
        End Get
        Set(value As Boolean)
            SetProperty(_IsNewTextboxReadOnly, value)
        End Set
    End Property

    Private _IsCheckboxEnabled As Boolean = True

    Public Sub New()
        Debug.WriteLine("-------------------")
        InitializeComponent()
    End Sub

    Public Property IsCheckboxEnabled As Boolean
        Get
            Return _IsCheckboxEnabled
        End Get
        Set(value As Boolean)
            SetProperty(_IsCheckboxEnabled, value)
        End Set
    End Property

    Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <CallerMemberName> Optional PropertyName As String = "") As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function
    Public Sub NotifyPropertyChanged(PropertyName As String)
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(PropertyName))
    End Sub
End Class

The result looks like this...


When the grid is disabled the new custom text control goes into Read Only mode.



Tuesday, October 1, 2024

Breaking the IsEnabled inheritance

One useful feature of WPF is that when you disable a container, all the controls in that container are disabled too. So if you want to put a page into read-only mode, you just have to disabled the page. The upside is simplicity and security. The downside to this is that there are probably some buttons you want the user to be able to click on.

The common technique to get around this is to put the buttons in a different container. However, this does not solve the problems associated with grids, lists, multi-line text boxes and other controls that need to respond to user interactions without allowing the user to edit them.

I read an interesting response to a question on StackOverflow see the first answer here https://stackoverflow.com/questions/14584662/enable-a-child-control-when-the-parent-is-disabled. It struck me as a very clever solution and definitely not a hack. I rewrote it in Visual Basic and created a full solution.

The idea is to wrap a component that should inherits its parent IsEnabled property in a container that breaks that inheritance. It's really quite elegant. 

Start a WPF, VB, .Net Framework Visual Studio project and call it BreakEnabledInheritance. It should work for .Net Core just as well.

Add a class called BreakEnabledInheritanceContainer. The code is very simple.

Public Class BreakEnabledInheritanceContainer
    Inherits ContentControl

    Shared Sub New()
        IsEnabledProperty.OverrideMetadata(GetType(BreakEnabledInheritanceContainer),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))
    End Sub

End Class

Now replace MainWindow.xaml with this. It creates two multi-line text boxes inside a disabled grid. The second text box is wrapped by a BreakEnabledInheritanceContainer and has IsReadOnly set true.
This leaves IsEnabled true and IsReadOnly true which allows the user to scroll it but not change it.

The first text box directly inherits the grid's IsEnabled so the user cannot interact with it at all.

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:BreakEnabledInheritance"
        DataContext="{Binding RelativeSource={RelativeSource self}}"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <Style TargetType="TextBox" x:Key="MultiLineTextBoxStyle" BasedOn="{StaticResource {x:Type TextBox}}">
            <Setter Property="TextWrapping" Value="Wrap"/>
            <Setter Property="AcceptsReturn" Value="True"/>
            <Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
            <Setter Property="VerticalContentAlignment" Value="Top"/>
        </Style>
    </Window.Resources>
    <Grid IsEnabled="false" Height="30">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <TextBox Grid.Column="0" Style="{StaticResource MultiLineTextBoxStyle}" Text="{Binding LongText}"/>
        <local:BreakEnabledInheritanceContainer Grid.Column="1">
            <TextBox Grid.Column="0" Style="{StaticResource MultiLineTextBoxStyle}" Text="{Binding LongText}" IsReadOnly="True"/>
        </local:BreakEnabledInheritanceContainer>
    </Grid>
</Window>

The code behind is trivial.
Class MainWindow
    Public Property LongText As String = "This is a long" & Environment.NewLine & "piece of text" & Environment.NewLine & "spread over several" & Environment.NewLine & "lines"
End Class

Here's the result...


This approach has the advantage that the preferred solution (controls inherit their parent's IsEnabled) can be used for most controls and we can override that functionality for selected controls. This reduces the chance of a developer accidently enabling a control that should be disabled and vice versa.

Displaying browsing history

I came across a good article that explains how to access a browser's recent history but it is written in C++ for some reason. I wanted to see how to do it using C# so I wrote this project. The original article is at https://www.codeproject.com/Articles/5388718/Displaying-recent-browsing-history If you don't subscribe to the CodeProject newsletters you can subscribe at https://www.codeproject.com/Feature/Insider/

You will need SQLite and also the .Net wrapper. Start a new WPF C# .NetCore project in Visual Studio and call it BrowserHistory. I'm going to display the Edge browser history but if you read the original article you can see how to easily modify the project to display the Chrome history.

Using Tools -> NuGet Package Manager -> Manage NuGet Packages for Solution, select Browse and search for SQLite (only one L). Install it. Now search for System.Data.SQLite and install that. Your solution looks like this.


Next we will write the XAML. Alter MainWindow.xaml to look like this. It defines a datagrid that binds to a property called dtHistory.

<Window x:Class="BrowserHistory.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:BrowserHistory"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="Edge Browser History" Height="450" Width="800">
    <Grid>
        <DataGrid ItemsSource="{Binding dtHistory}" IsReadOnly="True" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Visited (UT)" Binding="{Binding DateTimeStamp}" Width="auto"/>
                <DataGridTextColumn Header="URL" Binding="{Binding url}" Width="*"/>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

Now we can work on the fun stuff. The steps are.

  1. Find the browser history
  2. Make a copy (because the browser may have the original locked)
  3. Read parts of the browser history into a data table
  4. Convert microseconds since 1/1/1601 into a date time so we can display it
Step 1. The Edge browsing history for the current user can be found here...

        private String GetHistoryPath()
        {
            return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\Microsoft\\Edge\\User Data\\Default\\History";
        }

Step 2. We copy the database into the temp folder, deleting old copies first.

        private String CopyHistoryToTemp(String HistoryPath)
        {
            String TempPath = "";
            try
            {
                TempPath = Path.Combine(Path.GetTempPath(), "History");
                DeleteTemp(TempPath);
                File.Copy(HistoryPath, TempPath);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"CopyHistoryToTemp:{ex.Message}");
                Shutdown();
            }
            return TempPath;
        }

        private void DeleteTemp(String TempPath)
        {
            try
            {
                File.Delete(TempPath);
            }
            catch (Exception ex)
            {
                // Don't sweat it
            }
        }

Step 3. Open the database and read the history into a data table. 

        private DataTable GetHistoryDT(String TempPath)
        {
            SQLiteConnection db = null;
            String SQL = "SELECT u.url, v.visit_time FROM urls u JOIN visits v on u.id=v.url ORDER BY v.visit_time DESC;";
            SQLiteDataAdapter da;
            SQLiteCommand cmd;
            DataTable dt = new DataTable();

            try
            {
                db = new SQLiteConnection($"data source={TempPath}");
                db.Open();
                cmd = db.CreateCommand();
                cmd.CommandText = SQL;
                da = new SQLiteDataAdapter(cmd);
                da.Fill(dt);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"GetHistoryDT:{ex.Message}");
                Shutdown();
            }
            finally
            {
                if (db != null) db.Close();
            }
            return dt;
        }

Step 4. Create and populate a DateTimeStamp column because microseconds since 1/1/1601 isn't very useful.

        private DataTable PopulateDateTimeStamp(DataTable dt)
        {
            dt.Columns.Add(new DataColumn("DateTimeStamp", typeof(DateTime)));
            foreach (DataRow dr in dt.Rows)
            {
                dr["DateTimeStamp"] = ConvertWebkitToDateTime(Convert.ToInt64(dr["visit_time"]));
            }
            return dt;
        }

        private DateTime ConvertWebkitToDateTime(long WebKitTime)
        {
            return new DateTime(1601, 1, 1).AddMicroseconds(WebKitTime);
        }

Lastly we add a trivial method to terminate the application if there is an error.

        private void Shutdown()
        {
            Environment.Exit(0);
        }

If you run the program you will see results like this.



The complete MainWindow.xaml.cs looks like this.

using System.IO;
using System.Windows;
using System.Data.SQLite;
using System.Data;

namespace BrowserHistory
{
    public partial class MainWindow : Window
    {
        public DataTable dtHistory { get; set; }

        public MainWindow()
        {
            String HistoryPath = "";
            String TempPath = "";

            try
            {
                HistoryPath = GetHistoryPath();
                TempPath = CopyHistoryToTemp(HistoryPath);
                dtHistory = GetHistoryDT(TempPath);
                PopulateDateTimeStamp(dtHistory);
                InitializeComponent();
            }
            catch (Exception ex)
            {
                MessageBox.Show($"Main:{ex.Message}");
            }
            finally
            {
                DeleteTemp(TempPath);
            }
        }

        private String GetHistoryPath()
        {
            return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ,"\\Microsoft\\Edge\\User Data\\Default\\History");
        }

        private String CopyHistoryToTemp(String HistoryPath)
        {
            String TempPath = "";
            try
            {
                TempPath = Path.Combine(Path.GetTempPath(), "History");
                DeleteTemp(TempPath);
                File.Copy(HistoryPath, TempPath);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"CopyHistoryToTemp:{ex.Message}");
                Shutdown();
            }
            return TempPath;
        }

        private void DeleteTemp(String TempPath)
        {
            try
            {
                File.Delete(TempPath);
            }
            catch (Exception ex)
            {
                // Don't sweat it
            }
        }

        private DataTable GetHistoryDT(String TempPath)
        {
            SQLiteConnection db = null;
            String SQL = "SELECT u.url, v.visit_time FROM urls u JOIN visits v on u.id=v.url ORDER BY v.visit_time DESC;";
            SQLiteDataAdapter da;
            SQLiteCommand cmd;
            DataTable dt = new DataTable();

            try
            {
                db = new SQLiteConnection($"data source={TempPath}");
                db.Open();
                cmd = db.CreateCommand();
                cmd.CommandText = SQL;
                da = new SQLiteDataAdapter(cmd);
                da.Fill(dt);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"GetHistoryDT:{ex.Message}");
                DeleteTemp(TempPath);
                Shutdown();
            }
            finally
            {
                if (db != null) db.Close();
            }

            return dt;
        }

        private DataTable PopulateDateTimeStamp(DataTable dt)
        {
            dt.Columns.Add(new DataColumn("DateTimeStamp", typeof(DateTime)));
            foreach (DataRow dr in dt.Rows)
            {
                dr["DateTimeStamp"] = ConvertWebkitToDateTime(Convert.ToInt64(dr["visit_time"]));
            }
            return dt;
        }

        private DateTime ConvertWebkitToDateTime(long WebKitTime)
        {
            return new DateTime(1601, 1, 1).AddMicroseconds(WebKitTime);
        }

        private void Shutdown()
        {
            Environment.Exit(0);
        }
    }
}